Compare commits

..

22 Commits

Author SHA1 Message Date
60a78ed1e3 fix:实现园长签名页面 2026-01-25 23:50:40 +08:00
016e392524 fix:优化项目结构,去除历史代码 2026-01-24 23:53:43 +08:00
463c1c8b8f fix:修复项目的README文档 2026-01-24 23:48:44 +08:00
de71594812 fix(bug):修复转换PDF功能BUG,优化园长签名生成逻辑,让更多的文档支持园长签名功能 2026-01-24 23:38:38 +08:00
f3d16ec1f9 fix(bug):修复转换PDF功能BUG,优化园长签名生成逻辑,让更多的文档支持园长签名功能 2026-01-24 23:38:03 +08:00
1c2d5db393 flix:优化了数据查看页面的样式,让样式更加好看 2026-01-02 23:13:45 +08:00
d1f6d7da7d fix:实现数据核对页面 2026-01-01 00:28:22 +08:00
1c9aa24202 fix:修复一些命名规范问题 2025-12-31 08:37:06 +08:00
54398e2cbe fix:修复模板问题,修复系统操作逻辑,修复系统的一些BUG 2025-12-31 08:28:45 +08:00
d3c0121632 fix:修复一些BUG 2025-12-22 11:34:16 +08:00
3c60b3e7ca fix:优化一些命名规范 2025-12-21 23:17:35 +08:00
ba7dd09037 fix:优化一些命名规范 2025-12-19 22:03:13 +08:00
f64f005292 fix:修复一些BUG 2025-12-19 21:37:46 +08:00
842a7cce64 fix:修复一些BUG 2025-12-19 21:37:44 +08:00
14b8c19dfe fix:实现配置功能,实现园长一键签名功能 2025-12-19 12:23:00 +08:00
0e47603d23 fix:修改页面滚动条样式 2025-12-15 11:08:04 +08:00
6809c6f2c6 fix:更新项目说明文档 2025-12-13 22:57:41 +08:00
3a4a9df751 fix:添加niceGui库美化页面 2025-12-13 21:13:25 +08:00
93d1e8687a fix:修复一些BUG 2025-12-13 19:44:27 +08:00
9d347f9bc9 fix: 适配没有英语名字的情况 2025-12-12 17:24:32 +08:00
cbf87d2569 fix:优化模板 2025-12-12 12:41:37 +08:00
7275699c25 fix:优化启动方式 2025-12-12 12:37:41 +08:00
57 changed files with 3673 additions and 1165 deletions

5
.gitignore vendored
View File

@@ -10,5 +10,8 @@ wheels/
.venv
output/*.pptx
output/*.pdf
data/images/*
data/*.xlsx
config.toml
.idea/
.trae/

View File

@@ -1,10 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="uv (growth_report) (2)" jdkType="Python SDK" />
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="uv (growth_report)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
@@ -14,7 +12,7 @@
<component name="TemplatesService">
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
<option value="$MODULE_DIR$/script/dist/尚城幼儿园幼儿学期发展报告/templates" />
</list>
</option>
</component>

View File

@@ -1,56 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="loguru" />
<option value="APScheduler" />
<option value="watchdog" />
<option value="aiohttp" />
<option value="aiofiles" />
<option value="pydantic" />
<option value="SQLAlchemy" />
<option value="aiosqlite" />
<option value="fastapi" />
<option value="uvicorn" />
<option value="python-multipart" />
<option value="jinja2" />
<option value="itsdangerous" />
<option value="pillow" />
<option value="filetype" />
<option value="pydub" />
<option value="pysilk-mod" />
<option value="pymediainfo" />
<option value="py7zr" />
<option value="requests" />
<option value="httpx" />
<option value="tabulate" />
<option value="qrcode" />
<option value="psutil" />
<option value="tomli_w" />
<option value="websockets" />
<option value="redis" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
<option value="N802" />
<option value="N803" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="type.*" />
</list>
</option>
</inspection_tool>
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

4
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="uv (growth_report) (2)" />
<option name="sdkName" value="uv (growth_report)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="uv (growth_report) (2)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="uv (growth_report)" project-jdk-type="Python SDK" />
</project>

164
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="fix优化一些命名规范">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 0
}</component>
<component name="ProjectId" id="3744WiSuPrq64wZVLisMf4zKTFq" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Python.main_nicegui.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager">
<configuration name="main_nicegui" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="growth_report" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main_nicegui.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Python.main_nicegui" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-PY-253.29346.142" />
<option value="bundled-python-sdk-f2b7a9f6281b-6e1f45a539f7-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.29346.142" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="" />
<created>1766149044347</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1766149044347</updated>
<workItem from="1766149046808" duration="4371000" />
<workItem from="1766155304645" duration="34000" />
<workItem from="1766155493490" duration="387000" />
<workItem from="1766155896565" duration="429000" />
<workItem from="1766160535280" duration="21000" />
<workItem from="1766256207534" duration="960000" />
<workItem from="1766287241685" duration="2135000" />
<workItem from="1766329711762" duration="741000" />
<workItem from="1768312728552" duration="228000" />
<workItem from="1768312972093" duration="486000" />
<workItem from="1768314152581" duration="7000" />
</task>
<task id="LOCAL-00001" summary="fix修复一些BUG">
<option name="closed" value="true" />
<created>1766151464355</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1766151464355</updated>
</task>
<task id="LOCAL-00002" summary="fix修复一些BUG">
<option name="closed" value="true" />
<created>1766151466883</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1766151466883</updated>
</task>
<task id="LOCAL-00003" summary="fix优化一些命名规范">
<option name="closed" value="true" />
<created>1766152994686</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1766152994686</updated>
</task>
<task id="LOCAL-00004" summary="fix优化一些命名规范">
<option name="closed" value="true" />
<created>1766330259490</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1766330259490</updated>
</task>
<option name="localTasksCounter" value="5" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="fix修复一些BUG" />
<MESSAGE value="fix优化一些命名规范" />
<option name="LAST_COMMIT_MESSAGE" value="fix优化一些命名规范" />
<option name="OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT" value="true" />
<option name="REFORMAT_BEFORE_PROJECT_COMMIT" value="true" />
<option name="REARRANGE_BEFORE_PROJECT_COMMIT" value="true" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/growth_report$main_nicegui.coverage" NAME="main_nicegui 覆盖结果" MODIFIED="1766329725535" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
</component>
</project>

20
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.singleQuote": true,
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/dist": true,
".idea/**": true,
"**/__pycache__": true
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}

View File

@@ -2,7 +2,7 @@
## 项目概述
这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持双界面运行(命令行界面和图形界面)具备字体安装、图片替换、批量PDF转换、生肖计算功能。
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持UI界面操作具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
## 技术栈
@@ -15,6 +15,7 @@
- **loguru**: 日志记录
- **tomli**: 配置文件解析
- **tkinter**: 图形用户界面
- **nicegui**: 现代Web界面
## 核心功能
@@ -46,17 +47,23 @@
- 批量更新Excel中的生肖信息
- 支持日期格式自动识别
### 6. 导出数据模板 (📦 导出数据模板)
### 6. 园长一键签名 (💴 园长一键签名)
- 一键为所有生成的报告添加园长签名
- 自动识别输出文件夹中的PPT文件
- 批量替换签名占位符为实际签名图片
### 7. 导出数据模板 (📦 导出数据模板)
- 生成标准化的数据模板ZIP包
- 包含示例Excel文件和图片文件夹结构
- 方便新用户快速上手
### 7. 初始化系统 (📤 初始化系统)
### 8. 初始化系统 (📤 初始化系统)
- 自动创建必要的目录结构
- 安装所需字体文件
- 配置系统环境
### 8. 字体安装 (🔤 字体安装)
### 9. 字体安装 (🔤 字体安装)
- 自动检测系统是否安装了指定字体
- 自动安装项目所需的字体文件
- 支持方正兰亭黑简体和方正少儿简体字体
@@ -66,8 +73,10 @@
```
growth_report/
├── main.py # 主程序入口(命令行界面)
├── UI.py # 图形用户界面入口
├── config.env.toml # 项目配置文件
├── UI.py # 图形用户界面入口tkinter
├── main_nicegui.py # NiceGUI界面入口
├── main.pyw # Windows图形界面启动文件
├── config.toml # 项目配置文件
├── pyproject.toml # 项目依赖配置
├── start_app.bat # Windows启动批处理文件
├── README.md # 项目说明文档
@@ -75,6 +84,19 @@ growth_report/
├── config/
│ ├── config.py # 配置加载工具
│ └── output/ # 配置输出目录
├── ui/
│ ├── app_window.py # tkinter图形界面
│ ├── main_nicegui.py # NiceGUI界面主文件
│ ├── assets/
│ │ ├── icon.ico # 应用图标
│ │ └── style.css # 样式文件
│ ├── core/
│ │ ├── logger.py # 日志处理
│ │ ├── state.py # 应用状态管理
│ │ ├── task_runner.py # 任务运行器
│ │ └── __pycache__/
│ └── views/
│ └── home_page.py # NiceGUI主页面
├── utils/
│ ├── agent_utils.py # AI评语生成工具
│ ├── file_utils.py # 文件操作工具
@@ -82,6 +104,7 @@ growth_report/
│ ├── generate_utils.py # 核心生成功能
│ ├── growt_utils.py # PPT模板替换工具
│ ├── image_utils.py # 图片处理工具
│ ├── log_handler.py # 日志处理器
│ ├── pdf_utils.py # PDF转换工具
│ ├── pptx_utils.py # PPT文本和图片替换工具
│ └── zodiac_utils.py # 生肖计算工具
@@ -153,11 +176,16 @@ data/images/
python main.py
```
#### 图形界面
#### 图形界面 (tkinter)
```bash
python UI.py
```
#### NiceGUI界面 (现代Web界面)
```bash
python main_nicegui.py
```
或直接运行批处理文件:
```bash
start_app.bat
@@ -177,18 +205,18 @@ pip install -r requirements.txt
### 初始化设置
1. 编辑`config.env.toml`配置文件设置API密钥和其他参数
1. 编辑`config.toml`配置文件设置API密钥和其他参数
2. 准备Excel数据文件按指定格式
3. 准备图片资源文件夹(按指定结构)
4. 准备PPT模板文件
## 系统特点
- **界面支持**: 提供命令行界面和图形界面两种操作方式
- **界面支持**: 提供命令行界面、tkinter图形界面和NiceGUI现代Web界面三种操作方式
- **自动化流程**: 从数据到成品报告的全流程自动化
- **AI集成**: 智能生成个性化评语
- **格式保持**: 替换文本时保持原有格式
- **用户友好**: 丰富的命令行界面和图形界面,实时日志显示
- **用户友好**: 丰富的命令行界面和多种图形界面,实时日志显示
- **批量处理**: 支持批量生成和转换
- **错误处理**: 完善的异常处理和日志记录
- **生肖计算**: 自动根据生日计算生肖
@@ -200,7 +228,7 @@ pip install -r requirements.txt
- 使用`loguru`进行日志记录
- 使用`rich`美化命令行输出
- 使用`tkinter`构建图形界面
- 使用`tkinter``nicegui`构建图形界面
- 配置文件使用TOML格式
- 图片和文本替换使用占位符机制
- 遵循Python代码规范

136
README.md
View File

@@ -5,7 +5,7 @@
## 项目概述
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持界面操作(命令行界面和图形界面)具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持UI界面操作具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
## ✨ 主要特性
@@ -13,10 +13,11 @@
- 🤖 **AI评语**: 智能生成个性化、治愈系风格的幼儿评语
- 🖼️ **图文并茂**: 支持个人照片、活动照片、班级合影的自动替换
- 📄 **格式转换**: 批量PPT转PDF便于分发和存档
- 🎨 **界面**: 提供命令行界面和图形界面,满足不同用户需求
- 🎨 **现代界面**: 提供NiceGUI现代Web界面操作直观友好
- 🐲 **生肖计算**: 根据生日自动计算生肖信息
- 📦 **模板导出**: 生成标准化数据模板,快速上手
- 🔤 **字体安装**: 自动检测和安装所需字体文件
- ✍️ **签名生成**: 不依赖占位符,直接在指定位置添加园长签名
## 🛠️ 技术栈
@@ -27,7 +28,7 @@
- **comtypes**: PowerPoint转PDF功能
- **rich**: 美化命令行界面
- **loguru**: 日志记录
- **tkinter**: 图形用户界面
- **nicegui**: 现代Web界面
- **tomli**: 配置文件解析
## 📋 系统要求
@@ -42,46 +43,46 @@
```bash
git clone https://gitee.com/hanhanshibaobei/growthreport.git
cd growthreport
cd growth_report
```
### 2. 安装依赖
#### 使用uv推荐
```bash
uv sync
```
#### 使用pip
```bash
pip install -r requirements.txt
```
### 3. 配置系统
编辑 `config.env.toml` 文件,设置以下信息:
编辑 `config.toml` 文件,设置以下信息:
- AI API密钥和配置
- 班级信息和教师名单
- 文件路径配置
- 签名位置配置(可选)
### 4. 运行程序
#### 图形界面(推荐
#### NiceGUI界面推荐现代Web界面
```bash
python UI.py
python main.py
```
或直接运行:
```bash
start_app.bat
```
#### 命令行界面
```bash
python main.py
```
## 📖 使用指南
### 功能模块
@@ -94,24 +95,25 @@ python main.py
6. **📦 导出数据模板**: 生成标准化模板
7. **📤 初始化系统**: 配置系统环境
8. **🔤 字体安装**: 自动安装和检测所需字体
9. **✍️ 生成签名**: 不依赖占位符,直接在指定位置添加园长签名
### Excel数据格式
Excel文件应包含以下列顺序必须与配置文件中一致
| 列名 | 说明 | 示例 |
|------|------|------|
| 姓名 | 学生姓名 | 张小明 |
| 英文名 | 英文昵称 | Tom |
| 性别 | 性别 | 男 |
| 生日 | 出生日期 | 2019-03-15 |
| 属相 | 生肖(自动计算) | 猪 |
| 我的好朋友 | 好朋友姓名 | 李小红 |
| 我的爱好 | 个人爱好 | 画画、唱歌 |
| 喜欢的游戏 | 喜欢的游戏 | 积木、捉迷藏 |
| 喜欢吃的食物 | 喜欢的食物 | 苹果、饼干 |
| 评价 | AI生成的评语 | 自动填充 |
| 表现特征 | 表现关键词(可选) | 活泼、聪明 |
| 列名 | 说明 | 示例 |
| ------------ | ------------------ | ------------ |
| 姓名 | 学生姓名 | 张小明 |
| 英文名 | 英文昵称 | Tom |
| 性别 | 性别 | 男 |
| 生日 | 出生日期 | 2019-03-15 |
| 属相 | 生肖(自动计算) | 猪 |
| 我的好朋友 | 好朋友姓名 | 李小红 |
| 我的爱好 | 个人爱好 | 画画、唱歌 |
| 喜欢的游戏 | 喜欢的游戏 | 积木、捉迷藏 |
| 喜欢吃的食物 | 喜欢的食物 | 苹果、饼干 |
| 评价 | AI生成的评语 | 自动填充 |
| 表现特征 | 表现关键词(可选) | 活泼、聪明 |
### 图片文件结构
@@ -126,20 +128,47 @@ data/images/
支持多种图片格式:.jpg, .jpeg, .png
### 签名配置
`config.toml` 文件中,你可以配置签名图片的位置和大小:
```toml
[paths]
signature_image = "data/signature.png" # 签名图片路径
# 签名位置配置(可选)
signature_left = 2987040 # 左位置
signature_top = 8273415 # 上位置
signature_width = 1800000 # 宽度
signature_height = 720000 # 高度
```
如果不配置签名位置,系统会使用默认值。
## 📁 项目结构
```
growth_report/
├── main.py # 主程序入口(命令行界面)
├── UI.py # 图形用户界面入口
├── config.env.toml # 项目配置文件
├── main.py # NiceGUI界面入口
├── config.toml # 项目配置文件
├── pyproject.toml # 项目依赖配置
├── start_app.bat # 启动脚本
├── README.md # 项目说明文档
├── IFLOW.md # 项目详细说明文档
├── config/
│ ├── config.py # 配置加载工具
│ └── output/ # 配置输出目录
├── ui/
│ ├── assets/
│ │ ├── icon.ico # 应用图标
│ │ └── style.css # 样式文件
│ ├── core/
│ │ ├── logger.py # 日志处理
│ │ ├── state.py # 应用状态管理
│ │ └── task_runner.py # 任务运行器
│ └── views/
│ ├── config_page.py # 配置页面
│ ├── data_page.py # 数据页面
│ └── home_page.py # 主页面
├── utils/
│ ├── agent_utils.py # AI评语生成工具
│ ├── file_utils.py # 文件操作工具
@@ -147,16 +176,17 @@ growth_report/
│ ├── generate_utils.py # 核心生成功能
│ ├── growt_utils.py # PPT模板替换工具
│ ├── image_utils.py # 图片处理工具
│ ├── log_handler.py # 日志处理器
│ ├── pdf_utils.py # PDF转换工具
│ ├── pptx_utils.py # PPT文本和图片替换工具
│ ├── template_utils.py # 模板处理工具
│ └── zodiac_utils.py # 生肖计算工具
├── data/
│ ├── names.xlsx # 学生数据Excel文件
│ └── images/ # 学生图片资源文件夹
│ ├── signature.png # 签名图片
├── fonts/ # 字体文件目录
├── templates/ # PPT模板文件
├── output/ # 生成的报告输出目录
└── old/ # 旧版本文件备份
└── public/ # 公共资源文件
```
## 🤖 AI评语生成
@@ -169,11 +199,13 @@ growth_report/
- 表现特征
评语风格为"治愈系",采用三段式结构:
1. **开头**: 亲切问候 + 总体印象
2. **正文**: 具体描述孩子的进步和优点
3. **结尾**: 委婉期望 + 新学期祝福
支持分龄侧重评价:
- **小班 (3-4岁)**: 适应集体生活、情绪稳定性、基本生活自理能力
- **中班 (4-5岁)**: 社交互动、分享与合作、动手能力、好奇心
- **大班 (5-6岁)**: 学习习惯、逻辑思维、领导力、幼小衔接准备
@@ -182,39 +214,55 @@ growth_report/
### AI配置
`config.env.toml` 中配置AI模型
`config.toml` 中配置AI模型
```toml
[ai]
api_key = "your-api-key"
api_url = "https://api.openai.com/v1"
model = "gpt-3.5-turbo"
api_url = "https://apis.iflow.cn/v1/chat/completions"
model = "deepseek-v3.2"
prompt = """
你的评语风格是"治愈系"的,能让家长读完后感到欣慰...
# Role
你是一位拥有20年经验的资深幼儿园主班老师。你的文笔温暖、细腻、充满爱意擅长发现每个孩子身上独特的闪光点。你的评语风格是“治愈系”的能让家长读完后感到欣慰并对未来充满希望。
# Goal
请根据用户提供的【幼儿姓名】、【年龄段/班级】以及【日常表现关键词/评分数据】,撰写一份高质量的学期末成长评语。
"""
```
### 自定义PPT模板
### 签名配置
1.`templates/` 目录放置PPT模板
2. 使用占位符格式:`{{变量名}}`
3. 支持的占位符:
- `{{name}}`: 学生姓名
- `{{class_name}}`: 班级名称
- `{{comments}}`: 评语内容
- 等等...
`config.toml` 中配置签名位置:
```toml
[paths]
signature_image = "data/signature.png"
# 签名位置配置
signature_left = 2987040 # 左位置
signature_top = 8273415 # 上位置
signature_width = 1800000 # 宽度
signature_height = 720000 # 高度
```
## 🐛 常见问题
### Q: PDF转换失败怎么办
A: 请确保已安装Microsoft PowerPoint并且没有其他程序占用PPT文件。
### Q: AI评语生成失败
A: 检查API密钥配置是否正确网络连接是否正常。
### Q: 字体显示异常?
A: 系统会自动安装所需字体,如仍有问题请手动安装 `fonts/` 目录下的字体文件。
### Q: 签名生成失败?
A: 检查签名图片路径是否正确,以及签名位置配置是否合适。
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。

225
UI.py
View File

@@ -1,225 +0,0 @@
import tkinter as tk
from tkinter import ttk
from tkinter import scrolledtext
from tkinter import messagebox
from tkinter import filedialog
import threading
import sys
import time
import queue
import re
from loguru import logger
from config.config import load_config
# 假设你的功能函数都在这里
from utils.generate_utils import (
generate_template,
generate_comment_all,
batch_convert_folder,
generate_report,
generate_zodiac,
)
from utils.file_utils import export_templates_folder, initialize_project, export_data
# ==========================================
# 0. 全局配置与队列准备
# ==========================================
config = load_config("config.toml")
log_queue = queue.Queue()
def ansi_cleaner(text):
"""【辅助函数】去除 loguru 输出中的 ANSI 颜色代码"""
ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
return ansi_escape.sub("", text)
def queue_sink(message):
"""【核心】loguru sink 回调"""
clean_msg = ansi_cleaner(message)
log_queue.put(clean_msg)
# ==========================================
# GUI 主程序类
# ==========================================
class ReportApp:
def __init__(self, root):
self.root = root
self.root.title("🌱 尚城幼儿园成长报告助手")
self.root.geometry("720x680") # 高度稍微增加一点以容纳分组
# 设置样式
self.style = ttk.Style()
self.style.theme_use("clam")
self.style.configure("TButton", font=("微软雅黑", 10), padding=5)
self.style.configure("Title.TLabel", font=("微软雅黑", 16, "bold"), foreground="#2E8B57")
self.style.configure("Sub.TLabel", font=("微软雅黑", 9), foreground="gray")
# LabelFrame 的标题样式
self.style.configure("TLabelframe.Label", font=("微软雅黑", 10, "bold"), foreground="#0055a3")
# --- 1. 标题区域 ---
header_frame = ttk.Frame(root, padding="10 15 10 5")
header_frame.pack(fill=tk.X)
ttk.Label(header_frame, text="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack()
ttk.Label(header_frame, text="By 寒寒", style="Sub.TLabel").pack()
# --- 2. 按钮功能区域 (使用 LabelFrame 分组) ---
# 容器 Frame给四周留点白
main_content = ttk.Frame(root, padding=10)
main_content.pack(fill=tk.X)
# === A组: 核心功能 ===
func_btns = [
("📁 生成图片路径", self.run_generate_folders),
("🤖 生成评语 (AI)", self.run_generate_comments),
("📊 生成报告 (PPT)", self.run_generate_report),
("📑 格式转换 (PDF)", self.run_convert_pdf),
("🐂 生肖转化 (生日)", self.run_zodiac),
]
self.create_btn_group(main_content, "🛠️ 核心功能", func_btns, columns=3)
# === B组: 数据导出 ===
export_btns = [
("📦 导出数据模板 (Zip)", self.run_export_data_folder),
("📤 导出数据备份 (Zip)", self.run_export_data),
]
self.create_btn_group(main_content, "📦 数据管理", export_btns, columns=2)
# === C组: 系统设置 ===
system_btns = [
("⚠️ 初始化系统 (重置)", self.run_initialize_project),
("🚪 退出系统", self.quit_app),
]
self.create_btn_group(main_content, "⚙️ 系统操作", system_btns, columns=2)
# --- 3. 日志输出区域 ---
log_frame = ttk.LabelFrame(root, text="📝 系统实时日志", padding=10)
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
self.log_text = scrolledtext.ScrolledText(
log_frame, height=10, state="disabled", font=("Consolas", 9)
)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 启动日志轮询
self.root.after(100, self.poll_log_queue)
logger.info("GUI 初始化完成,等待指令...")
def create_btn_group(self, parent, title, buttons, columns=2):
"""
辅助函数:快速创建分组按钮
:param parent: 父容器
:param title: 分组标题
:param buttons: 按钮列表 [(text, func), ...]
:param columns: 每行显示几个按钮
"""
frame = ttk.LabelFrame(parent, text=title, padding=10)
frame.pack(fill=tk.X, pady=5) # 垂直堆叠
for index, (text, func) in enumerate(buttons):
# 特殊处理:如果是"初始化"或"退出",可以用不同的样式(可选,这里暂不做)
btn = ttk.Button(frame, text=text, command=func)
# 动态计算网格位置
r, c = divmod(index, columns)
btn.grid(row=r, column=c, padx=8, pady=5, sticky="ew")
# 配置列权重,让按钮自动填满宽度
for i in range(columns):
frame.columnconfigure(i, weight=1)
# --- 核心方法:轮询队列 ---
def poll_log_queue(self):
while not log_queue.empty():
try:
msg = log_queue.get_nowait()
self.log_text.config(state="normal")
self.log_text.insert(tk.END, msg)
self.log_text.see(tk.END)
self.log_text.config(state="disabled")
except queue.Empty:
break
self.root.after(100, self.poll_log_queue)
# --- 线程包装器 ---
def run_in_thread(self, target_func, *args):
def thread_task():
try:
target_func(*args)
logger.success("✅ 当前任务执行完毕。")
except Exception as e:
logger.error(f"❌ 发生错误: {str(e)}")
import traceback
logger.error(traceback.format_exc())
threading.Thread(target=thread_task, daemon=True).start()
# ==========================================
# 按钮事件 (业务逻辑保持不变)
# ==========================================
def run_generate_folders(self):
self.run_in_thread(generate_template)
def run_generate_comments(self):
self.run_in_thread(generate_comment_all)
def run_generate_report(self):
self.run_in_thread(generate_report)
def run_convert_pdf(self):
self.run_in_thread(batch_convert_folder, config["output_folder"])
def run_zodiac(self):
self.run_in_thread(generate_zodiac)
def run_export_data_folder(self):
target_folder = filedialog.askdirectory(
title="请选择导出数据保存的文件夹",
initialdir=config.get("output_folder", ".")
)
if not target_folder:
logger.warning("🚫 导出操作已取消")
return
logger.info(f"已选择保存路径: {target_folder}")
self.run_in_thread(export_templates_folder, target_folder)
def run_initialize_project(self):
self.run_in_thread(initialize_project)
def run_export_data(self):
target_folder = filedialog.askdirectory(
title="请选择导出数据保存的文件夹",
initialdir=config.get("output_folder", ".")
)
if not target_folder:
logger.warning("🚫 导出操作已取消")
return
logger.info(f"已选择保存路径: {target_folder}")
self.run_in_thread(export_data, target_folder)
def quit_app(self):
if messagebox.askokcancel("退出", "确定要退出系统吗?"):
self.root.destroy()
sys.exit()
# ==========================================
# 启动入口
# ==========================================
def applicationUI():
logger.add(
queue_sink,
format="{time:HH:mm:ss} | {level: <8} | {message}",
level="INFO",
)
root = tk.Tk()
app = ReportApp(root)
root.mainloop()
if __name__ == "__main__":
applicationUI()

View File

@@ -4,9 +4,9 @@ source_file = "大班幼儿学期发展报告.pptx"
# 输出文件夹
output_folder = "output"
# Excel数据文件路径
excel_file = "data/names.xlsx"
excel_file = "names.xlsx"
# 图片资源文件夹
image_folder = "data/images"
image_folder = "images"
# 字体文件夹
fonts_dir = "fonts"

26
config.toml Normal file
View File

@@ -0,0 +1,26 @@
[paths]
source_file = "大四班 幼儿学期发展报告.pptx"
output_folder = "output"
excel_file = "names.xlsx"
image_folder = "images"
fonts_dir = "fonts"
signature_image = "d:\\working\\tools\\growth_report\\data\\signature.png"
[class_info]
class_name = "K4D"
teachers = [
"康璐璐",
"冯宇阳",
"孙继艳",
]
class_type = 2
[defaults]
default_comment = ""
age_group = "大班上学期"
[ai]
api_key = "sk-ccb6a1445126f2e56ba50d7622ff350f"
api_url = "https://apis.iflow.cn/v1/chat/completions"
model = "deepseek-v3.2"
prompt = "# Role \n你是一位拥有20年经验的资深幼儿园主班老师。你的文笔温暖、细腻、充满爱意擅长发现每个孩子身上独特的闪光点。你的评语风格是“治愈系”的能让家长读完后感到欣慰并对未来充满希望。\n\n# Goal\n请根据用户提供的【幼儿姓名】、【年龄段/班级】以及【日常表现关键词/评分数据】,撰写一份高质量的学期末成长评语。\n# Constraints & Rules\n1. **严格的格式排版 (Strict Formatting)**:\n- **换行**:正文中间不要随意换行,保持为一段完整的段落。\n2. **称呼处理**:\n- 自动识别用户输入的姓名,去掉姓氏。\n- 例如:“王小明” -> 第一行输出“小明宝贝:”。\n3. **分龄侧重 (根据 Age_Group 调整侧重点)**:\n- **小班 (3-4岁)**:侧重于适应集体生活、情绪稳定性、基本生活自理能力、愿意与老师互动。\n- **中班 (4-5岁)**:侧重于社交互动、分享与合作、动手能力、好奇心、规则意识。\n- **大班 (5-6岁)**:侧重于学习习惯、逻辑思维、领导力、任务意识、幼小衔接准备。\n4. **写作结构 (固定内容)**:\n- **开头**:固定文本必须包含:“{class_type}”\n- **正文**:结合【表现关键词】和【性别】,具体描述进步和优点。\n- **结尾**:委婉地提出期望(“如果你能...老师会更为你骄傲”),并送上祝福。\n5. **语气风格**:\n- 积极正面,多用肯定句。\n- 字数控制在 150-250 字之间。\n# Input Format\n- Name {{name}}\n- Age_Group {{class_name}}\n- Traits {{traits}}\n- Sex {{sex}}\n# Output Example\n(假设输入:Name=张图图, Age_Group=小班, Traits=适应能力强, 爱笑, 挑食,Sex=女)\n亲爱的图图宝贝{class_type}\n你是一个爱笑的小天使每天早上都能看到你甜甜的笑脸。从一开始的哭鼻子到现在能开心地参与游戏你的适应能力让老师感到惊喜。不过老师发现你在吃饭时偶尔会把不喜欢的青菜挑出来哦。如果你能和青菜宝宝做好朋友把身体练得棒棒的那就更完美啦祝可爱的图图宝贝新年快乐健康成长\n"

View File

@@ -1,82 +1,145 @@
import os
import sys
# 尝试导入 toml 解析
# 1. 处理读取
try:
import tomllib as toml # Python 3.11+
import tomllib as toml_read # Python 3.11+
except ImportError:
try:
import tomli as toml # pip install tomli
import tomli as toml_read
except ImportError:
print("错误: 缺少 TOML 解析库。请运行: pip install tomli")
print("错误: 缺少 TOML 读取库。请运行: pip install tomli")
sys.exit(1)
# 2. 处理写入库 (必须安装 pip install tomli-w)
try:
import tomli_w as toml_write
except ImportError:
# 如果没安装,提供一个 fallback 提示
toml_write = None
def get_main_path():
"""
获取程序运行的根目录
兼容:
1. PyInstaller 打包后的 .exe 环境
2. 开发环境 (假设此脚本在子文件夹中,如 utils/)
"""
def get_base_dir():
if getattr(sys, "frozen", False):
# --- 情况 A: 打包后的 exe ---
# exe 就在根目录下,直接取 exe 所在目录
return os.path.dirname(sys.executable)
else:
# --- 情况 B: 开发环境 (.py) ---
# 1. 获取当前脚本的绝对路径 (例如: .../MyProject/utils/config_loader.py)
current_file_path = os.path.abspath(__file__)
# 假设当前文件在项目根目录或根目录下的某个文件夹中
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 2. 获取当前脚本所在的文件夹 (例如: .../MyProject/utils)
current_dir = os.path.dirname(current_file_path)
# 3. 【关键修改】再往上一层,获取项目根目录 (例如: .../MyProject)
# 如果你的脚本藏得更深,就再套一层 os.path.dirname
project_root = os.path.dirname(current_dir)
return project_root
def get_resource_path(relative_path):
base_path = get_base_dir()
external_path = os.path.join(base_path, relative_path)
if os.path.exists(external_path):
return external_path
if getattr(sys, "frozen", False):
internal_path = os.path.join(sys._MEIPASS, relative_path)
if os.path.exists(internal_path):
return internal_path
return external_path
# ==========================================
# 1. 配置加载 (Config Loader)
# ==========================================
def load_config(config_filename="config.toml"):
"""读取 TOML 配置文件"""
# 1. 先获取正确的根目录
main_dir = get_main_path()
# 2. 拼接配置文件的绝对路径 (防止在不同目录下运行脚本时找不到配置文件)
config_path = os.path.join(main_dir, config_filename)
config_path = get_resource_path(config_filename)
if not os.path.exists(config_path):
print(f"错误: 在路径 {main_dir} 下找不到配置文件 {config_filename}")
print(f"尝试寻找的完整路径是: {config_path}")
sys.exit(1)
# 如果彻底找不到,返回一个最小化的默认值,防止程序奔溃
return {"source_file": "", "ai": {"api_key": ""}, "teachers": []}
try:
with open(config_path, "rb") as f:
data = toml.load(f)
data = toml_read.load(f)
base_dir = get_base_dir()
# 使用 .get() 安全获取,防止 KeyError: 'paths'
paths = data.get("paths", {})
class_info = data.get("class_info", {})
defaults = data.get("defaults", {})
# 将 TOML 的层级结构映射回扁平结构
# 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录)
config = {
"root_path": main_dir, # 方便调试,把根目录也存进去
"source_file": os.path.join(
main_dir, "templates", data["paths"]["source_file"]
"root_path": base_dir,
"data_folder": os.path.join(os.path.join("data")),
# 扁平化映射
"source_file": get_resource_path(
os.path.join("templates", paths.get("source_file", ""))
),
"excel_file": get_resource_path(
os.path.join("data", paths.get("excel_file", ""))
),
"image_folder": get_resource_path(
os.path.join("data", paths.get("image_folder", ""))
),
"fonts_dir": get_resource_path(paths.get("fonts_dir", "fonts")),
"output_folder": os.path.join(
base_dir, paths.get("output_folder", "output")
),
"signature_image": get_resource_path(
os.path.join("data", paths.get("signature_image", "signature.png"))
),
"class_name": class_info.get("class_name", "未命名班级"),
"teachers": class_info.get("teachers", []),
"class_type": class_info.get("class_type", 0),
"default_comment": defaults.get("default_comment", "暂无评语"),
"age_group": defaults.get("age_group", "大班上学期"),
"ai": data.get(
"ai", {"api_key": "", "api_url": "", "model": "", "prompt": ""}
),
"output_folder": os.path.join(main_dir, data["paths"]["output_folder"]),
"excel_file": os.path.join(main_dir, data["paths"]["excel_file"]),
"image_folder": os.path.join(main_dir, data["paths"]["image_folder"]),
"fonts_dir": os.path.join(main_dir, data["paths"]["fonts_dir"]),
"class_name": data["class_info"]["class_name"],
"teachers": data["class_info"]["teachers"],
"default_comment": data["defaults"].get("default_comment", "暂无评语"),
"age_group": data["defaults"].get("age_group", "大班上学期"),
"ai": data["ai"],
}
return config
except Exception as e:
print(f"读取配置文件出错: {e}")
sys.exit(1)
print(f"解析配置文件失败: {e}")
return {}
# ==========================================
# 2. 配置保存 (Config Saver)
# ==========================================
def save_config(config_data, config_filename="config.toml"):
if not toml_write:
return False, "未安装 tomli-w 库,无法保存。请运行 pip install tomli-w"
base_path = get_base_dir()
save_path = os.path.join(base_path, config_filename)
try:
# 将扁平化的数据重新打包成嵌套结构,以适配 load_config 的读取逻辑
new_data = {
"paths": {
"source_file": os.path.basename(config_data.get("source_file", "")),
"output_folder": os.path.basename(
config_data.get("output_folder", "output")
),
"excel_file": os.path.basename(config_data.get("excel_file", "")),
"image_folder": os.path.basename(config_data.get("image_folder", "")),
"fonts_dir": os.path.basename(config_data.get("fonts_dir", "fonts")),
"signature_image": get_resource_path(
os.path.join(
"data", config_data.get("signature_image", "signature.png")
)
),
},
"class_info": {
"class_name": config_data.get("class_name", ""),
"teachers": config_data.get("teachers", []),
"class_type": config_data.get("class_type", 0),
},
"defaults": {
"default_comment": config_data.get("default_comment", ""),
"age_group": config_data.get("age_group", ""),
},
"ai": config_data.get("ai", {}),
}
# 写入文件
with open(save_path, "wb") as f:
f.write(toml_write.dumps(new_data).encode("utf-8"))
return True, f"成功保存到: {save_path}"
except Exception as e:
return False, f"写入失败: {str(e)}"

Binary file not shown.

BIN
data/signature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

229
main.py
View File

@@ -1,117 +1,126 @@
import os
import sys
import traceback
from loguru import logger
from nicegui import ui, app, run, native
from screeninfo import get_monitors
from config.config import load_config
from utils.generate_utils import (
generate_template,
generate_comment_all,
generate_report,
batch_convert_folder,
generate_zodiac,
)
from utils.file_utils import export_templates_folder, initialize_project, export_data
config = load_config("config.yaml")
# 导入我们的模块
from ui.core.logger import setup_logger
from ui.views.config_page import create_config_page
from ui.views.home_page import create_home_page
from ui.views.data_page import create_data_page
from ui.views.signature_page import create_signature_page
from utils.font_utils import install_fonts_from_directory
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# 1. 初始化配置
config = load_config("config.toml")
setup_logger()
def application():
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich.align import Align
from rich import box
import sys
# === 关键修改:定义一个获取路径的通用函数 ===
def get_path(relative_path):
"""
获取资源的绝对路径。
兼容:开发环境(直接运行) 和 生产环境(打包成exe后解压的临时目录)
"""
if hasattr(sys, "_MEIPASS"):
base_path = sys._MEIPASS
else:
# 开发环境当前目录
base_path = os.path.dirname(os.path.abspath(__file__))
console = Console()
while True:
console.clear()
# 1. 创建一个表格,不显示表头,使用圆角边框
table = Table(box=None, show_header=False, padding=(0, 2))
# 2. 添加两列:序号列(居右),内容列(居左)
table.add_column(justify="right", style="cyan bold")
table.add_column(justify="left")
# 3. 添加行内容
table.add_row("1.", "📁 生成图片路径(每一个幼儿一个图片文件夹)")
table.add_row("2.", "🤖 生成评语(根据姓名、学段、性别)")
table.add_row("3.", "📊 生成报告(根据表格生成)")
table.add_row("4.", "📑 格式转换PPT转PDF")
table.add_row("5.", "📑 生肖转化(根据生日)")
table.add_row("6.", "📦 导出数据模板Zip")
table.add_row("7.", "📦 初始化系统")
table.add_row("8.", "📤 导出数据")
table.add_row("9.", "🚪 退出系统")
# 4. 将表格放入面板,并居中显示
panel = Panel(
Align.center(table),
title="[bold green]🌱 幼儿园成长报告助手",
subtitle="[dim]By 寒寒",
width=60,
border_style="bright_blue",
box=box.ROUNDED, # 圆角边框更柔和
)
# 使用 Align.center 让整个菜单在屏幕中间显示
console.print(Align.center(panel, vertical="middle"))
console.print("\n") # 留点空隙
choice = Prompt.ask(
"👉 请输入序号执行",
choices=["1", "2", "3", "4", "5", "6", "7", "8", "9"],
default="1",
)
try:
if choice == "1":
console.rule("[bold cyan]正在执行: 生成模板[/]")
with console.status(
"[bold green]正在创建文件夹结构...[/]", spinner="dots"
):
generate_template()
elif choice == "2":
console.rule("[bold yellow]正在执行: AI 生成评语[/]")
# 这里的 generate_comment_all 最好内部有进度条,或者简单的 print
generate_comment_all()
elif choice == "3":
console.rule("[bold blue]正在执行: PPT 合成[/]")
with console.status(
"[bold blue]正在处理图片和文字...[/]", spinner="earth"
):
generate_report()
elif choice == "4":
console.rule("[bold magenta]正在执行: PDF 批量转换[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
batch_convert_folder(config["output_folder"])
elif choice == "5":
console.rule("[bold magenta]正在执行: 生肖转化[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
generate_zodiac()
elif choice == "6":
console.rule("[bold magenta]正在执行: 导出数据模板[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
export_templates_folder()
elif choice == "7":
console.rule("[bold magenta]正在执行: 初始化系统[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
initialize_project()
elif choice == "8":
console.rule("[bold magenta]正在执行: 导出数据[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
export_data()
elif choice == "9":
console.print("[bold red]👋 再见![/]")
sys.exit()
Prompt.ask("按 [bold]Enter[/] 键返回主菜单...")
except Exception as e:
console.print(
Panel(
f"[bold red]❌ 发生错误:[/]\n{e}", title="Error", border_style="red"
)
)
Prompt.ask("按 Enter 键继续...")
return os.path.join(base_path, relative_path)
if __name__ == "__main__":
application()
def calculate_window_size():
"""
获取主屏幕分辨率,并计算一个基于百分比的 NiceGUI 窗口大小。
"""
try:
# 尝试获取所有显示器信息
monitors = get_monitors()
if monitors:
# 假设第一个是主显示器
m = monitors[0]
screen_width = m.width
screen_height = m.height
# 设置窗口宽度为屏幕宽度的 30%
target_width = int(screen_width * 0.30)
# 设置窗口高度为屏幕高度的 60%
target_height = int(screen_height * 0.60)
# 确保窗口有一个合理的最小值 (例如 800x600)
min_width = 800
min_height = 700
target_width = max(target_width, min_width)
target_height = max(target_height, min_height)
logger.info(f"屏幕分辨率: {screen_width}x{screen_height}")
logger.info(f"设置窗口大小为: {target_width}x{target_height}")
return target_width, target_height
except Exception as e:
logger.warning(f"无法获取屏幕分辨率 ({e}),使用默认大小 (900, 900)")
return 900, 900 # 失败时的默认值
# 1. 挂载静态资源 (CSS/图片)
# 注意:这里使用 get_path 确保打包后能找到
static_dir = get_path(os.path.join("ui", "assets"))
app.add_static_files("/assets", static_dir)
# 3. 页面路由
@ui.page("/")
def index_page():
create_home_page()
@ui.page("/config")
def config_page():
create_config_page()
@ui.page("/data")
def data_page():
create_data_page()
@ui.page("/signature")
def signature_page(folder: str = ""):
create_signature_page(folder)
# 4. 启动时钩子
async def startup_check():
try:
logger.info("系统启动: 初始化资源...")
await run.io_bound(install_fonts_from_directory, config["fonts_dir"])
os.makedirs(config["output_folder"], exist_ok=True)
logger.success("资源初始化完成")
except Exception as e:
logger.error(f"初始化失败: {e}")
logger.error(traceback.format_exc())
app.on_startup(startup_check)
if __name__ in {"__main__", "__mp_main__"}:
calculated_size = calculate_window_size()
ui.run(
title="尚城幼儿园成长报告助手",
native=True,
window_size=calculated_size,
port=native.find_open_port(), # 自动寻找端口
reload=True,
)

39
main.spec Normal file
View File

@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.pyw'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='main',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['public\\icon.ico'],
)

View File

@@ -1,415 +0,0 @@
import os
import platform
import shutil
import sys
import time
from pathlib import Path
import pandas as pd
from pptx import Presentation
from pptx.util import Pt
def get_system_fonts():
"""获取系统中可用的字体列表"""
fonts = set()
if platform.system() == "Windows":
try:
# 读取Windows字体目录
system_fonts_dir = Path(os.environ['WINDIR']) / 'Fonts'
user_fonts_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
# 检查系统字体目录
if system_fonts_dir.exists():
for font_file in system_fonts_dir.glob('*.ttf'):
fonts.add(font_file.stem)
for font_file in system_fonts_dir.glob('*.ttc'):
fonts.add(font_file.stem)
for font_file in system_fonts_dir.glob('*.otf'):
fonts.add(font_file.stem)
# 检查用户字体目录
if user_fonts_dir.exists():
for font_file in user_fonts_dir.glob('*.ttf'):
fonts.add(font_file.stem)
for font_file in user_fonts_dir.glob('*.ttc'):
fonts.add(font_file.stem)
for font_file in user_fonts_dir.glob('*.otf'):
fonts.add(font_file.stem)
except Exception as e:
print(f"读取系统字体时出错: {e}")
# 备选方案:返回常见字体
fonts = {"微软雅黑", "宋体", "黑体", "楷体", "仿宋", "Arial", "Times New Roman", "Courier New",
"Microsoft YaHei"}
return fonts
def is_font_available(font_name):
"""检查字体是否在系统中可用"""
system_fonts = get_system_fonts()
# 检查字体名称的多种可能形式
check_names = [font_name, font_name.replace(" ", ""), font_name.replace("-", ""), font_name.lower(),
font_name.upper()]
for name in check_names:
if name in system_fonts:
return True
return False
def install_fonts_from_directory(fonts_dir="fonts"):
"""从指定目录安装字体到系统"""
if platform.system() != "Windows":
print("字体安装功能目前仅支持Windows系统")
return False
# 获取系统字体目录
user_fonts_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
# 优先使用用户字体目录(不需要管理员权限)
target_font_dir = user_fonts_dir
# 创建目标目录(如果不存在)
target_font_dir.mkdir(parents=True, exist_ok=True)
# 检查字体目录是否存在
if not os.path.exists(fonts_dir):
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
return False
# 遍历字体目录中的字体文件
font_extensions = ['.ttf', '.ttc', '.otf', '.fon']
font_files = []
for ext in font_extensions:
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
if not font_files:
print(f"{fonts_dir} 目录中未找到字体文件 (.ttf, .ttc, .otf, .fon)")
return False
# 安装字体文件
installed_fonts = []
for font_file in font_files:
try:
# 复制字体文件到系统字体目录
target_path = target_font_dir / font_file.name
if not target_path.exists(): # 避免重复安装
shutil.copy2(font_file, target_path)
print(f"已安装字体: {font_file.name}")
installed_fonts.append(font_file.name)
else:
pass
# print(f"字体已存在: {font_file.name}") # 减少日志输出
except Exception as e:
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
continue
if installed_fonts:
print(f"共安装了 {len(installed_fonts)} 个新字体文件")
print("注意新安装的字体可能需要重启Python环境后才能在PowerPoint中使用")
return True
else:
print("没有安装新字体文件,可能已存在于系统中")
return False
def replace_text_in_slide(prs, slide_index, placeholder, text):
"""
在指定幻灯片中替换指定占位符的文本,并保持原有格式。
:param prs: Presentation 对象
:param slide_index: 要操作的幻灯片索引从0开始
:param placeholder: 占位符名称 (shape.name)
:param text: 要替换的文本
"""
slide = prs.slides[slide_index]
for shape in slide.shapes:
if shape.name == placeholder:
if not shape.has_text_frame:
continue
# --- 1. 保存原有格式信息 ---
original_paragraph_formats = []
for paragraph in shape.text_frame.paragraphs:
paragraph_format = {
'alignment': paragraph.alignment,
'space_before': getattr(paragraph, 'space_before', None),
'space_after': getattr(paragraph, 'space_after', None),
'line_spacing': getattr(paragraph, 'line_spacing', None),
# [修复] 补充读取缩进属性,防止后面恢复时 KeyError
'left_indent': getattr(paragraph, 'left_indent', None),
'right_indent': getattr(paragraph, 'right_indent', None),
'first_line_indent': getattr(paragraph, 'first_line_indent', None),
'font_info': []
}
for run in paragraph.runs:
run_format = {
'font_name': run.font.name,
'font_size': run.font.size,
'bold': run.font.bold,
'italic': run.font.italic,
'underline': run.font.underline,
'color': run.font.color,
'character_space': getattr(run.font, 'space', None),
'all_caps': getattr(run.font, 'all_caps', None),
'small_caps': getattr(run.font, 'small_caps', None)
}
paragraph_format['font_info'].append(run_format)
original_paragraph_formats.append(paragraph_format)
# --- 2. 设置新文本内容 ---
shape.text = text
# --- 3. 恢复原有格式 ---
for i, paragraph in enumerate(shape.text_frame.paragraphs):
# 如果新文本段落数超过原格式数量,使用最后一个格式或默认格式
orig_idx = i if i < len(original_paragraph_formats) else -1
if not original_paragraph_formats: break # 防止空列表
original_para = original_paragraph_formats[orig_idx]
# 恢复段落格式
if original_para['alignment'] is not None:
paragraph.alignment = original_para['alignment']
if original_para['space_before'] is not None:
paragraph.space_before = original_para['space_before']
if original_para['space_after'] is not None:
paragraph.space_after = original_para['space_after']
if original_para['line_spacing'] is not None:
paragraph.line_spacing = original_para['line_spacing']
if original_para['left_indent'] is not None:
paragraph.left_indent = original_para['left_indent']
if original_para['right_indent'] is not None:
paragraph.right_indent = original_para['right_indent']
if original_para['first_line_indent'] is not None:
paragraph.first_line_indent = original_para['first_line_indent']
# 恢复字体格式 (尽量应用到所有 runs)
# 注意shape.text = text 会把所有内容变成一个 run但也可能有多个
for j, run in enumerate(paragraph.runs):
# 通常取第一个run的格式或者按顺序取
font_idx = j if j < len(original_para['font_info']) else 0
if not original_para['font_info']: break
original_font = original_para['font_info'][font_idx]
# 字体名称
if original_font['font_name'] is not None:
font_name = original_font['font_name']
if is_font_available(font_name):
run.font.name = font_name
else:
run.font.name = "微软雅黑"
# print(f"警告: 字体 '{font_name}' 不可用,已替换")
# 其他属性
if original_font['font_size'] is not None:
run.font.size = original_font['font_size']
if original_font['bold'] is not None:
run.font.bold = original_font['bold']
if original_font['italic'] is not None:
run.font.italic = original_font['italic']
if original_font['underline'] is not None:
run.font.underline = original_font['underline']
if original_font['character_space'] is not None:
try:
run.font.space = original_font['character_space']
except:
pass
if original_font['all_caps'] is not None:
run.font.all_caps = original_font['all_caps']
# 颜色处理
if original_font['color'] is not None:
try:
# 仅当它是RGB类型时才复制主题色可能导致报错
if hasattr(original_font['color'], 'rgb'):
run.font.color.rgb = original_font['color'].rgb
except:
pass
def replace_picture(prs, slide_index, placeholder, img_path):
"""
在指定幻灯片中替换指定占位符的图片。
:param prs: Presentation 对象
:param slide_index: 要操作的幻灯片索引从0开始
:param placeholder: 占位符名称
:param img_path: 要替换的图片路径
"""
if not os.path.exists(img_path):
print(f"警告: 图片路径不存在 {img_path}")
return
slide = prs.slides[slide_index]
sp_tree = slide.shapes._spTree
# 先找到要替换的形状及其索引位置
for i, shape in enumerate(slide.shapes):
if shape.name == placeholder:
# 保存旧形状的位置信息
left = shape.left
top = shape.top
width = shape.width
height = shape.height
# 从幻灯片中移除旧图片占位符
sp_tree.remove(shape._element)
# 添加新图片
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
# 插入新图片到旧形状原来的位置
sp_tree.insert(i, new_shape._element)
break
if __name__ == "__main__":
# --- 1. 资源准备 ---
# 安装字体如果存在fonts目录
if install_fonts_from_directory("../fonts"):
print("等待系统识别新安装的字体...")
time.sleep(2)
source_file = r"../templates/大班幼儿学期发展报告.pptx"
output_folder = "output"
excel_file = os.path.join("../data/names.xlsx")
image_folder = os.path.join("../data/images")
# 创建输出文件夹
os.makedirs(output_folder, exist_ok=True)
# 检查源文件是否存在
if not os.path.exists(source_file):
print(f"错误: 找不到模版文件 {source_file}")
sys.exit(1)
if not os.path.exists(excel_file):
print(f"错误: 找不到数据文件 {excel_file}")
sys.exit(1)
# --- 2. 定义辅助函数 (显式传入 prs) ---
def replace_one_page(current_prs, name, class_name):
"""替换第一页信息"""
replace_text_in_slide(current_prs, 0, "name", name)
replace_text_in_slide(current_prs, 0, "class", class_name)
def replace_two_page(current_prs, comments, teacher_name):
"""替换第二页信息"""
replace_text_in_slide(current_prs, 1, "comments", comments)
replace_text_in_slide(current_prs, 1, "teacher_name", teacher_name)
def replace_three_page(current_prs, name, english_name, sex, birthday, zodiac, friend, hobby, game, food, me_image):
"""替换第三页信息
:param current_prs: Presentation对象当前演示文稿
:param name: 学生姓名
:param english_name: 学生英文名
:param sex: 性别
:param birthday: 生日
:param zodiac: 生肖
:param friend: 好朋友
:param hobby: 爱好
:param game: 爱玩的游戏
:param food: 爱吃的食物
:param me_image: 自己的照片
"""
replace_text_in_slide(current_prs, 2, "name", name)
replace_text_in_slide(current_prs, 2, "english_name", english_name)
replace_text_in_slide(current_prs, 2, "sex", sex)
replace_text_in_slide(current_prs, 2, "birthday", birthday)
replace_text_in_slide(current_prs, 2, "zodiac", zodiac)
replace_text_in_slide(current_prs, 2, "friend", friend)
replace_text_in_slide(current_prs, 2, "hobby", hobby)
replace_text_in_slide(current_prs, 2, "game", game)
replace_text_in_slide(current_prs, 2, "food", food)
replace_picture(current_prs, 2, "me_image", me_image)
def replace_four_page(current_prs, class_image):
"""替换第四页信息"""
replace_picture(current_prs, 3, "class_image", class_image)
def replace_five_page(current_prs, image1, image2):
"""替换第五页信息"""
replace_picture(current_prs, 4, "image1", image1)
replace_picture(current_prs, 4, "image2", image2)
# --- 3. 读取数据并处理 ---
try:
df = pd.read_excel(excel_file, sheet_name="Sheet1")
datas = df[["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物",
"评价"]].values.tolist()
class_name = "K4D"
teacher_name = ["简蜜", "王敏千", "李玉香"]
print(f"开始处理,共 {len(datas)} 位学生...")
# --- 4. 循环处理 ---
for i, (name, english_name, sex, birthday, zodiac, friend, hobby, game, food, comments) in enumerate(datas):
# 班级图片
print(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# [修复] 每次循环重新加载模版,保证文件干净
prs = Presentation(source_file)
# 替换第一页内容
replace_one_page(prs, name, class_name)
# 替换第二页内容
teacher_names = " ".join(teacher_name)
replace_two_page(prs, comments, teacher_names)
# 替换第三页内容
student_image_folder = os.path.join(image_folder, name)
if os.path.exists(student_image_folder):
me_image = os.path.join(student_image_folder, "me_image.jpg")
replace_three_page(prs, name, english_name, sex, birthday, zodiac, friend, hobby, game, food, me_image)
else:
print(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# 替换第四页内容
class_image = os.path.join(image_folder, class_name + ".jpg")
if os.path.exists(class_image):
replace_four_page(prs, class_image)
else:
print(f"错误: 班级图片文件不存在 {class_image}")
continue
# 替换第五页内容
if os.path.exists(student_image_folder):
image1 = os.path.join(student_image_folder, "1.jpg")
image2 = os.path.join(student_image_folder, "1.jpg")
replace_five_page(prs, image1, image2)
else:
print(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# 获取文件拓展名
file_ext = os.path.splitext(source_file)[1]
safe_name = str(name).strip() # 去除可能存在的空格
new_filename = f"{class_name} {safe_name} 幼儿成长报告{file_ext}"
output_path = os.path.join(output_folder, new_filename)
# 保存
try:
prs.save(output_path)
except PermissionError:
print(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。")
print("\n所有报告生成完毕!")
except Exception as e:
print(f"程序运行出错: {str(e)}")
import traceback
traceback.print_exc()

BIN
public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -9,13 +9,19 @@ dependencies = [
"langchain>=1.1.3",
"langchain-openai>=1.1.1",
"loguru>=0.7.3",
"nicegui>=3.4.0",
"openpyxl>=3.1.5",
"pandas>=2.3.3",
"pandas-stubs==2.3.3.251201",
"pillow>=12.0.0",
"pyinstaller>=6.17.0",
"python-pptx>=1.0.2",
"pywebview>=6.1",
"pywin32>=311",
"rich>=14.2.0",
"screeninfo>=0.8.1",
"tomli>=2.3.0",
"tomli-w>=1.2.0",
]
[[tool.uv.index]]

112
script/setup.py Normal file
View File

@@ -0,0 +1,112 @@
# setup.py
import os
import subprocess
import sys
import shutil
MAIN_FILE = "main_nicegui.py"
def copy_resources():
"""
将资源文件从项目根目录复制到 dist 文件夹中,
以便用户可以直接在 exe 旁边修改这些文件。
"""
# 1. 定义路径
# setup.py 所在的目录 (script/)
current_dir = os.path.dirname(os.path.abspath(__file__))
# 项目根目录 (script/ 的上一级)
project_root = os.path.dirname(current_dir)
# 输出目录 (script/dist)
dist_dir = os.path.join(current_dir, "dist")
print(f"\n--- 正在复制外部资源到 {dist_dir} ---")
if not os.path.exists(dist_dir):
print("错误: dist 文件夹不存在,请先运行打包。")
return
# 2. 定义要复制的资源清单
# 格式: (源路径相对root, 目标文件夹相对dist)
# 如果目标是根目录,用 "" 表示
resources_to_copy = [
("config.toml", ""), # 复制 config.toml 到 dist/
("fonts", "fonts"), # 复制 fonts 文件夹 到 dist/fonts
("data", "data"), # 复制 data 文件夹 到 dist/data
("templates", "templates"), # 复制 templates 文件夹 到 dist/templates
("public", "public"), # 复制 public 文件夹 到 dist/public
('ui/assets', 'ui/assets'), # 复制 ui/assets 文件夹 到 dist/ui/assets
]
for src_name, dest_name in resources_to_copy:
src_path = os.path.join(project_root, src_name)
dest_path = os.path.join(dist_dir, dest_name)
try:
if os.path.isfile(src_path):
# --- 复制文件 ---
shutil.copy2(src_path, dest_path) # copy2 保留文件元数据
print(f"✅ 已复制文件: {src_name}")
elif os.path.isdir(src_path):
# --- 复制文件夹 ---
# dirs_exist_ok=True 允许覆盖已存在的目录 (Python 3.8+)
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
print(f"✅ 已复制目录: {src_name}")
else:
print(f"⚠️ 警告: 源文件不存在,跳过: {src_path}")
except Exception as e:
print(f"❌ 复制失败 {src_name}: {e}")
def build_exe():
"""使用 PyInstaller 打包 main_app.py"""
# --- 内部资源 (打入包内的资源) ---
# 即使我们在外部复制了一份,为了保证 exe 独立运行(万一外部文件被删),
# 建议依然保留这些作为“默认出厂设置”打入包内。
resource_paths = [
"--add-data=../config.toml:.",
"--add-data=../fonts:fonts",
"--add-data=../data:data",
"--add-data=../templates:templates",
"--add-data=../public:public",
]
try:
command = [
sys.executable, "-m", "PyInstaller",
"--onefile",
"--windowed", # 调试阶段建议先注释掉,确认无误后再开启
"--name=尚城幼儿园幼儿学期发展报告",
"--icon=../public/icon.ico",
"../" + MAIN_FILE
]
# 添加资源参数
command.extend(resource_paths)
print("--- 开始打包 (PyInstaller) ---")
# 运行 PyInstaller
subprocess.run(command, check=True, cwd=os.path.dirname(os.path.abspath(__file__)))
print("\n--- PyInstaller 打包完成!---")
# === 执行资源复制 ===
copy_resources()
print(f"\n🎉 全部完成!请查看 'dist' 文件夹。")
except subprocess.CalledProcessError as e:
print(f"\n--- 打包失败 ---")
print(f"命令执行出错: {e}")
except FileNotFoundError:
print(f"\n--- 打包失败 ---")
print("错误:找不到 PyInstaller。")
if __name__ == "__main__":
build_exe()

107
script/setup_nicegui.py Normal file
View File

@@ -0,0 +1,107 @@
import os
import subprocess
import sys
import shutil
import platform
MAIN_FILE = "main_nicegui.py"
# 【关键修改】提取应用名称为变量,确保打包目录和复制目录一致
APP_NAME = "尚城幼儿园幼儿学期发展报告"
def copy_resources():
"""
将资源文件从项目根目录复制到 dist/APP_NAME 文件夹中 (与 exe 同级)
以便用户可以直接在 exe 旁边修改这些文件。
"""
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
# 【关键修改】目标目录改为 dist/APP_NAME
# 这样资源文件才会出现在 exe 旁边,而不是 dist 根目录
dist_dir = os.path.join(current_dir, "dist", APP_NAME)
print(f"\n--- 正在复制外部资源到 {dist_dir} ---")
if not os.path.exists(dist_dir):
print(f"错误: 目标文件夹不存在: {dist_dir}")
print("请检查是否打包成功,或者是否使用了 --onedir 模式。")
return
# 这里的列表是【给用户看/改的】
resources_to_copy = [
("config.toml", ""),
("fonts", "fonts"),
("data", "data"),
("templates", "templates"),
("public", "public"),
]
for src_name, dest_name in resources_to_copy:
src_path = os.path.join(project_root, src_name)
dest_path = os.path.join(dist_dir, dest_name)
try:
if os.path.isfile(src_path):
shutil.copy2(src_path, dest_path)
print(f"✅ 已复制文件: {src_name}")
elif os.path.isdir(src_path):
# dirs_exist_ok=True 允许覆盖已存在的目录Python 3.8+
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
print(f"✅ 已复制目录: {src_name}")
else:
print(f"⚠️ 警告: 源文件不存在,跳过: {src_path}")
except Exception as e:
print(f"❌ 复制失败 {src_name}: {e}")
def build_exe():
"""使用 PyInstaller 打包"""
sep = ';' if platform.system() == "Windows" else ':'
# 内部资源 (打入 exe 内部的)
resource_paths = [
f"../config.toml{sep}.",
f"../fonts{sep}fonts",
f"../templates{sep}templates",
f"../ui/assets{sep}ui/assets",
]
try:
command = [
sys.executable, "-m", "PyInstaller",
"--onedir", # 文件夹模式
"--windowed", # 隐藏控制台 (调试时建议先注释掉)
f"--name={APP_NAME}", # 【关键修改】使用变量
"--clean",
"--distpath=./dist",
"--workpath=./build",
"--icon=../public/icon.ico",
"../" + MAIN_FILE
]
for res in resource_paths:
command.append(f"--add-data={res}")
command.extend(["--hidden-import=nicegui", "--hidden-import=uvicorn"])
print("--- 开始打包 (PyInstaller) ---")
subprocess.run(command, check=True, cwd=os.path.dirname(os.path.abspath(__file__)))
print("\n--- PyInstaller 打包完成!---")
# 打包完成后执行复制
copy_resources()
# 打印最终 exe 的位置提示
exe_path = os.path.join("dist", APP_NAME, f"{APP_NAME}.exe")
print(f"\n🎉 全部完成!可执行文件位于: {exe_path}")
except subprocess.CalledProcessError as e:
print(f"\n❌ 打包失败: {e}")
except Exception as e:
print(f"\n❌ 发生错误: {e}")
if __name__ == "__main__":
build_exe()

View File

@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['..\\main_nicegui.py'],
pathex=[],
binaries=[],
datas=[('../config.toml', '.'), ('../fonts', 'fonts'), ('../templates', 'templates'), ('../ui/assets', 'ui/assets')],
hiddenimports=['nicegui', 'uvicorn'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='尚城幼儿园幼儿学期发展报告',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['..\\public\\icon.ico'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='尚城幼儿园幼儿学期发展报告',
)

View File

@@ -4,7 +4,7 @@
chcp 65001 >nul
:: ------------------------------------------------
title 幼儿园成长报告助手
title 幼儿园成长报告助手启动器
cd /d "%~dp0"
echo.
@@ -13,7 +13,7 @@ echo 正在启动 幼儿园成长报告助手
echo ==========================================
echo.
:: 检查 uv 是否安装
:: 1. 检查 uv 是否安装
uv --version >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] 未检测到 uv 工具!
@@ -22,21 +22,28 @@ if %errorlevel% neq 0 (
exit /b
)
echo [INFO] 环境检查通过,正在运行主程序...
echo [INFO] 环境检查通过...
:: 2. 检查依赖是否安装 (可选,防止第一次运行报错)
:: 如果你有 pyproject.tomluv run 会自动处理,这一步可以省略
:: 这里为了保险,检查一下 loguru 是否存在,不存在则自动安装基础依赖
uv pip show loguru >nul 2>&1
if %errorlevel% neq 0 (
echo [INFO] 首次运行,正在安装依赖...
uv pip install loguru toml pandas pillow openpyxl python-pptx
)
echo [INFO] 正在拉起主程序...
echo ---------------------------------------------------
:: 这里的 gui_app.py 就是你刚才保存的那个带界面的 Python 文件名
:: 如果你的文件名不一样,请修改下面这一行
uv run UI.py
:: =======================================================
:: 【关键修改】路径改为根目录的 main.py
:: 使用 start 命令启动,这样黑色的 CMD 窗口可以随后立即关闭
:: =======================================================
start "" uv run main.py
:: 错误捕获
if %errorlevel% neq 0 (
echo.
echo ---------------------------------------------------
echo [ERROR] 程序异常退出 (代码: %errorlevel%)
echo 请检查上方报错信息。
pause
) else (
echo.
echo [INFO] 程序已正常结束。
)
:: 等待 1 秒确保启动
timeout /t 1 >nul
:: 退出 CMD 窗口 (让用户只看到 GUI)
exit

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
ui/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

182
ui/assets/style.css Normal file
View File

@@ -0,0 +1,182 @@
/* assets/style.css */
/* 全局字体 */
body {
font-family: '微软雅黑', 'Microsoft YaHei', sans-serif;
background-color: #f0f4f8;
}
/* 标题栏 */
.app-header {
background-color: #2e8b57; /* SeaGreen */
color: white;
}
/* 卡片通用样式 */
.func-card {
width: 100%;
padding: 1rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
border-radius: 0.5rem;
}
/* 核心功能区顶部边框 */
.card-core {
border-top: 4px solid #16a34a; /* green-600 */
}
/* 数据管理区顶部边框 */
.card-data {
border-top: 4px solid #3b82f6; /* blue-500 */
}
/* 系统操作区顶部边框 */
.card-system {
border-top: 4px solid #ef4444; /* red-500 */
}
.card-logging {
border-top: 4px solid #9c1be0;
}
/* 标题文字 */
.section-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
/* 绿色标题 */
.text-green {
color: #166534;
}
/* 蓝色标题 */
.text-blue {
color: #1e40af;
}
/* 红色标题 */
.text-red {
color: #991b1b;
}
/* assets/style.css */
/* ... (原有的样式) ... */
/* ---------------------------------- */
/* 滚动条隐藏样式 */
/* ---------------------------------- */
/*
* 1. 隐藏 Webkit 内核浏览器 (Chrome, Safari, Edge) 的滚动条
* 适用于 NiceGUI 默认的 Chromium 浏览器
*/
.hide-scrollbar::-webkit-scrollbar {
/* 完全隐藏滚动条 */
width: 0px;
background: transparent; /* 使滚动条轨道透明 */
}
/* 2. 隐藏 Firefox 浏览器的滚动条 */
.hide-scrollbar {
/* 设置滚动条宽度为 thin (细),比 auto (默认) 要窄 */
scrollbar-width: none; /* 'none' 是最新且更彻底的隐藏方式 */
/* 确保容器内容溢出时可以滚动 */
overflow: auto;
}
/* 示例:如果你只想隐藏日志区的滚动条 */
.card-logging .q-expansion-item__content .nicegui-log .q-scrollarea__content {
/* 如果 nicegui-log 内部使用了 q-scrollarea可能需要针对其内容应用样式 */
scrollbar-width: none;
}
/* ---------------------------------- */
/* 滚动条美化(可选 - 不隐藏,但变细变淡) */
/* 如果完全隐藏不好,可以试试这个更温和的方案 */
/* ---------------------------------- */
.thin-scrollbar::-webkit-scrollbar {
width: 6px; /* 调整宽度 */
height: 6px; /* 调整高度 */
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #a0a0a0; /* 拇指颜色 */
border-radius: 3px;
border: 1px solid #f0f4f8; /* 边框颜色 */
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar {
scrollbar-width: thin; /* Firefox 细滚动条 */
scrollbar-color: #a0a0a0 transparent; /* Firefox 颜色设置 */
}
*::-webkit-scrollbar {
/* 完全隐藏滚动条 */
width: 0px !important;
height: 0px !important;
background: transparent !important;
}
#nicegui-content,
#q-app {
/* 确保容器内容可以滚动,但滚动条被隐藏 */
overflow: auto;
/* Firefox 隐藏滚动条 */
scrollbar-width: none;
/* IE/Edge 隐藏滚动条 */
-ms-overflow-style: none;
}
/* 档案卡片容器 */
.profile-dialog {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
/* 档案条目样式 */
.info-item {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 12px;
padding: 14px 18px;
margin-bottom: 10px;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
}
.info-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08);
transform: translateY(-1px);
}
/* 标签与内容的对比度 */
.info-label {
font-size: 0.75rem;
font-weight: 700;
color: #94a3b8; /* 浅灰色标签 */
text-transform: uppercase;
letter-spacing: 0.05em;
width: 100px;
margin-top: 2px;
}
.info-value {
font-size: 0.95rem;
font-weight: 500;
color: #1e293b; /* 深色内容 */
line-height: 1.6;
word-break: break-all;
}
/* 自定义滚动条 */
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}

15
ui/core/logger.py Normal file
View File

@@ -0,0 +1,15 @@
import sys
from loguru import logger
from ui.core.state import app_state
class GuiLogger:
def write(self, message):
if app_state.log_element:
app_state.log_element.push(message.strip())
def setup_logger():
logger.remove()
# 控制台输出
logger.add(sys.stderr, format="{time:HH:mm:ss} | {level} | {message}")
# GUI 输出
logger.add(GuiLogger(), format="{time:HH:mm:ss} | {level} | {message}", level="INFO")

13
ui/core/state.py Normal file
View File

@@ -0,0 +1,13 @@
import threading
class AppState:
def __init__(self):
self.stop_event = threading.Event()
self.is_running = False
# 这些 UI 元素的引用将在 UI 初始化时被赋值
self.progress_bar = None
self.progress_label = None
self.log_element = None
# 创建全局单例
app_state = AppState()

73
ui/core/task_runner.py Normal file
View File

@@ -0,0 +1,73 @@
import tkinter as tk
from tkinter import filedialog
from nicegui import ui, run
from loguru import logger
from ui.core.state import app_state
async def select_folder():
"""在 Native 模式下弹窗选择文件夹"""
def _pick():
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
path = filedialog.askdirectory()
root.destroy()
return path
return await run.io_bound(_pick)
async def run_task(func, *args, **kwargs):
"""通用任务执行器"""
if app_state.is_running:
ui.notify("当前有任务正在运行,请稍候...", type="warning")
return
# 1. 状态重置
app_state.is_running = True
app_state.stop_event.clear()
if app_state.progress_bar: app_state.progress_bar.set_value(0)
if app_state.progress_label: app_state.progress_label.set_text("🚀 正在启动任务...")
# 2. 定义进度条回调
def progress_callback(current, total, task_name="任务"):
if total <= 0:
pct = 0
text = f"{task_name}: 准备中..."
else:
pct = current / total
text = f"{task_name}: {current}/{total} ({int(pct * 100)}%)"
# 更新 UI
if app_state.progress_bar: app_state.progress_bar.set_value(pct)
if app_state.progress_label: app_state.progress_label.set_text(text)
# 3. 组装参数
kwargs['progress_callback'] = progress_callback
kwargs['stop_event'] = app_state.stop_event
# 4. 执行
try:
# 适配器:检查函数是否接受 stop_event
def _exec():
try:
func(*args, **kwargs)
except TypeError:
kwargs.pop('stop_event', None)
func(*args, **kwargs)
await run.io_bound(_exec)
if app_state.progress_label: app_state.progress_label.set_text("✅ 任务完成")
ui.notify("任务执行成功!", type="positive")
except Exception as e:
logger.error(f"任务出错: {e}")
if app_state.progress_label: app_state.progress_label.set_text(f"❌ 错误: {str(e)}")
ui.notify(f"任务失败: {e}", type="negative")
finally:
app_state.is_running = False
if app_state.progress_bar: app_state.progress_bar.set_value(0)

189
ui/views/config_page.py Normal file
View File

@@ -0,0 +1,189 @@
from nicegui import ui
import os
from utils.template_utils import get_template_files
# 修改点 1统一导入避免与变量名 config 冲突
from config.config import load_config, save_config
def create_config_page():
# 修改点 2将加载逻辑放入页面生成函数内确保每次刷新页面获取最新值
conf_data = load_config("config.toml")
template_options = get_template_files()
current_filename = os.path.basename(conf_data.get("source_file", ""))
if current_filename and current_filename not in template_options:
template_options.append(current_filename)
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
# 样式修正:添加全屏且不滚动条的 CSS
ui.add_head_html(
"""
<style>
body { overflow: hidden; }
.main-card { height: calc(100vh - 100px); display: flex; flex-direction: column; }
.q-tab-panels { flex-grow: 1; overflow-y: auto !important; }
</style>
"""
)
with ui.header().classes("app-header items-center justify-between shadow-md"):
# 左侧:图标和标题
with ui.row().classes("items-center gap-2"):
ui.image("/assets/icon.ico").classes("w-8 h-8").props("fit=contain")
ui.label("尚城幼儿园成长报告助手").classes("text-xl font-bold")
# 右侧:署名 + 配置按钮
with ui.row().classes("items-center gap-4"):
ui.label("By 寒寒 | 这里的每一份评语都充满爱意").classes(
"text-xs opacity-90"
)
ui.button(icon="home", on_click=lambda: ui.navigate.to("/")).props(
"flat round color=white"
)
# 修改点 3使用 flex 布局撑满
with ui.card().classes("w-full max-w-5xl mx-auto shadow-lg main-card p-0"):
with ui.tabs().classes("w-full") as tabs:
tab_path = ui.tab("路径设置", icon="folder")
tab_class = ui.tab("班级与教师", icon="school")
tab_ai = ui.tab("AI 接口配置", icon="psychology")
with ui.tab_panels(tabs, value=tab_path).classes(
"w-full flex-grow bg-transparent"
):
# --- 路径设置 ---
with ui.tab_panel(tab_path).classes("w-full p-0"):
with ui.column().classes("w-full p-4 gap-4"):
source_file = (
ui.select(
options=template_options,
label="PPT 模板",
value=current_filename,
)
.props("outlined fill-input")
.classes("w-full")
)
excel_file = (
ui.input(
"Excel 文件",
value=os.path.basename(conf_data.get("excel_file", "")),
)
.props("outlined")
.classes("w-full")
)
image_folder = (
ui.input(
"图片目录",
value=os.path.basename(conf_data.get("image_folder", "")),
)
.props("outlined")
.classes("w-full")
)
output_folder = (
ui.input(
"输出目录",
value=os.path.basename(
conf_data.get("output_folder", "output")
),
)
.props("outlined")
.classes("w-full")
)
# --- 班级信息 ---
with ui.tab_panel(tab_class).classes("w-full p-0"):
with ui.column().classes("w-full p-4 gap-4"):
class_name = (
ui.input("班级名称", value=conf_data.get("class_name", ""))
.props("outlined")
.classes("w-full")
)
age_group = (
ui.select(
options=[
"小班上学期",
"小班下学期",
"中班上学期",
"中班下学期",
"大班上学期",
"大班下学期",
],
label="年龄段",
value=conf_data.get("age_group", "中班上学期"),
)
.props("outlined")
.classes("w-full")
)
teachers_text = (
ui.textarea(
"教师名单", value="\n".join(conf_data.get("teachers", []))
)
.props("outlined")
.classes("w-full h-40")
)
class_type = (
ui.select(
options={0: "便宜班", 1: "昂贵班", 2: "昂贵的双木桥班"},
label="班级类型",
value=conf_data.get("class_type", 0),
)
.props("outlined")
.classes("w-full")
)
# --- AI 配置 ---
with ui.tab_panel(tab_ai).classes("w-full p-0"):
with ui.column().classes("w-full p-4 gap-4"):
ai_key = (
ui.input("API Key", value=conf_data["ai"].get("api_key", ""))
.props("outlined password")
.classes("w-full")
)
ai_url = (
ui.input("API URL", value=conf_data["ai"].get("api_url", ""))
.props("outlined")
.classes("w-full")
)
ai_model = (
ui.input("Model Name", value=conf_data["ai"].get("model", ""))
.props("outlined")
.classes("w-full")
)
ai_prompt = (
ui.textarea(
"System Prompt", value=conf_data["ai"].get("prompt", "")
)
.props("outlined")
.classes("w-full h-full")
)
# 底部固定按钮
with ui.row().classes("w-full p-4"):
async def handle_save():
new_data = {
"source_file": source_file.value,
"excel_file": excel_file.value,
"image_folder": image_folder.value,
"output_folder": output_folder.value,
"class_name": class_name.value,
"age_group": age_group.value,
"signature_image": conf_data.get("signature_image", ""),
"teachers": [
t.strip() for t in teachers_text.value.split("\n") if t.strip()
],
"class_type": class_type.value,
"ai": {
"api_key": ai_key.value,
"api_url": ai_url.value,
"model": ai_model.value,
"prompt": ai_prompt.value,
},
}
# 修改点 4直接调用导入的 save_config 函数名
success, message = save_config(new_data)
ui.notify("配置已保存重启生效", type="positive")
ui.button("保存配置", on_click=handle_save).classes("w-full py-4").props(
"outline color=primary"
)

144
ui/views/data_page.py Normal file
View File

@@ -0,0 +1,144 @@
import pandas as pd
from nicegui import ui
from config.config import load_config
import os
def create_header():
with ui.header().classes("app-header items-center justify-between shadow-md"):
with ui.row().classes("items-center gap-2"):
ui.image("/assets/icon.ico").classes("w-8 h-8").props("fit=contain")
ui.label("尚城幼儿园成长报告助手").classes("text-xl font-bold")
with ui.row().classes("items-center gap-4"):
ui.label("By 寒寒 | 这里的每一份评语都充满爱意").classes(
"text-xs opacity-90"
)
ui.button(icon="home", on_click=lambda: ui.navigate.to("/")).props(
"flat round color=white"
).tooltip("回到首页")
def create_data_page():
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
create_header()
with ui.column().classes("w-full max-w-6xl mx-auto p-4 gap-4 thin-scrollbar"):
with ui.card().classes("func-card"):
with ui.row().classes("items-center justify-between w-full"):
ui.label("📊 班级幼儿数据").classes("section-title text-blue")
ui.button(
"刷新表格", icon="sync", on_click=lambda: ui.navigate.to("/data")
).props("outline color=primary")
with ui.card().classes("func-card"):
load_data()
def load_data():
conf_data = load_config("config.toml")
excel_path = conf_data.get("excel_file")
with (
ui.dialog() as detail_dialog,
ui.card().classes(
"w-[600px] p-0 profile-dialog rounded-3xl overflow-hidden shadow-2xl"
),
):
with ui.row().classes(
"w-full bg-gradient-to-r from-blue-50 to-indigo-50 items-center justify-between"
):
with ui.column().classes("gap-0"):
ui.label("幼儿成长档案").classes(
"text-xl p-4 font-black text-slate-800"
)
ui.button(icon="close", on_click=detail_dialog.close).props(
"flat round color=primary"
).classes("bg-white/50")
with ui.column().classes("w-full p-6 h-[450px] overflow-auto gap-0"):
content_container = ui.column().classes("w-full")
with ui.row().classes(
"w-full p-5 bg-slate-50/80 backdrop-blur-md border-t justify-end gap-3"
):
ui.button("确认", on_click=detail_dialog.close).props(
"unelevated color=blue-6"
).classes("px-10 rounded-xl shadow-lg shadow-blue-200 font-bold")
def handle_cell_click(e):
row_data = e.args["data"]
content_container.clear()
student_name = row_data.get("姓名", "详细数据")
with content_container:
with ui.row().classes(
"w-full items-center p-4 bg-blue-600 rounded-2xl shadow-md shadow-blue-100"
):
ui.avatar("person", color="white", text_color="blue-6").props(
"size=48px"
)
with ui.column().classes("gap-0 text-white"):
ui.label(student_name).classes("text-lg font-bold")
for key, value in row_data.items():
if key == "姓名":
continue
with ui.element("div").classes("info-item w-full flex flex-col gap-1"):
ui.label(key).classes("font-bold text-blue-800")
ui.label(str(value)).classes("info-value flex-1")
detail_dialog.open()
if not excel_path or not os.path.exists(excel_path):
with ui.column().classes("w-full items-center p-12 text-slate-400"):
ui.icon("folder_off", size="64px")
ui.label("数据文件未找到,请检查配置路径").classes("mt-4")
return
try:
df = pd.read_excel(excel_path)
for col in df.select_dtypes(include=["datetime"]):
df[col] = df[col].dt.strftime("%Y-%m-%d")
df = df.fillna("-")
with ui.row().classes(
"bg-blue-50 w-full p-3 px-6 items-center rounded-t-xl border-b border-blue-100"
):
ui.icon("fact_check", color="primary", size="20px")
ui.label(f"班级:{conf_data.get('class_name', '未设定')}").classes(
"text-sm font-bold text-blue-800"
)
ui.separator().props("vertical").classes("mx-2")
ui.label(f"共加载 {len(df)} 条幼儿记录").classes("text-xs text-slate-500")
ui.space()
ui.label("💡 提示:点击行可展开完整评语详情").classes(
"text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"
)
ui.aggrid(
{
"columnDefs": [
{
"headerName": col,
"field": col,
"sortable": True,
"filter": True,
"cellClass": "text-slate-600",
"suppressMovable": True,
}
for col in df.columns
],
"rowData": df.to_dict("records"),
"pagination": True,
"paginationPageSize": 20,
"theme": "balham",
}
).classes("w-full flex-grow h-[550px] border-none").on(
"cellClicked", handle_cell_click
)
except Exception as e:
ui.notify(f"加载数据时发生错误: {e}", type="negative", position="top")

120
ui/views/home_page.py Normal file
View File

@@ -0,0 +1,120 @@
from nicegui import ui
from config.config import load_config
from ui.core.state import app_state
from ui.core.task_runner import run_task, select_folder
# 导入业务函数
from utils.generate_utils import (
generate_template,
generate_comment_all,
generate_convert_pdf,
generate_report,
generate_zodiac,
)
from utils.file_utils import open_folder
config = load_config("config.toml")
def create_header():
with ui.header().classes("app-header items-center justify-between shadow-md"):
# 左侧:图标和标题
with ui.row().classes("items-center gap-2"):
ui.image("/assets/icon.ico").classes("w-8 h-8").props("fit=contain")
ui.label("尚城幼儿园成长报告助手").classes("text-xl font-bold")
# 右侧:署名 + 配置按钮
with ui.row().classes("items-center gap-4"):
ui.label("By 寒寒 | 这里的每一份评语都充满爱意").classes(
"text-xs opacity-90"
)
# 添加配置按钮
ui.button(
icon="settings", on_click=lambda: ui.navigate.to("/config")
).props("flat round color=white").tooltip("系统配置")
def create_home_page():
# 1. 引入外部 CSS
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
create_header()
# 主容器
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4 thin-scrollbar"):
# === 进度条区域 ===
with ui.card().classes("func-card"):
app_state.progress_label = ui.label("⛳ 任务进度: 待命").classes(
"font-bold text-gray-700 mb-1"
)
# 使用 NiceGUI 原生属性配合 CSS 类
app_state.progress_bar = ui.linear_progress(
value=0, show_value=False
).classes("h-4 rounded")
app_state.progress_bar.props("color=positive") # 使用 Quasar 颜色变量
# === 核心功能区 ===
with ui.card().classes("func-card card-core"):
ui.label("🛠️ 核心功能").classes("section-title text-green")
with ui.grid(columns=3).classes("w-full gap-3"):
# 辅助函数:快速创建按钮
def func_btn(text, func):
ui.button(text, on_click=lambda: run_task(func)).props(
f"outline"
).classes("w-full")
func_btn("📁 生成图片路径", generate_template)
func_btn("🤖 生成评语 (AI)", generate_comment_all)
func_btn("📊 生成报告 (PPT)", generate_report)
func_btn("📑 格式转换 (PDF)", generate_convert_pdf)
func_btn("🐂 生肖转化 (生日)", generate_zodiac)
# 签名按钮
async def on_signature_click():
selected_folder = await select_folder()
if selected_folder:
ui.navigate.to(f"/signature?folder={selected_folder}")
else:
ui.notify("未选择目录", type="warning")
ui.button("💴 园长签名", on_click=on_signature_click).props(
f"outline"
).classes("w-full")
# === 下方双栏布局 ===
# 数据管理
with ui.card().classes("func-card card-data"):
ui.label("⚙️ 系统操作").classes("section-title text-blue")
def stop_now():
if app_state.is_running:
app_state.stop_event.set()
ui.notify("发送停止信号...", type="warning")
with ui.row().classes("w-full"):
ui.button(
"📦 打开输出文件夹",
on_click=lambda: open_folder(config.get("output_folder")),
).props(f"outline")
ui.button(
"📤 打开数据文件夹",
on_click=lambda: open_folder(config.get("data_folder")),
).props(f"outline")
ui.button(
"🔍 查看数据",
on_click=lambda: ui.navigate.to("/data"),
).props(f"outline")
ui.button("⛔ 停止", on_click=stop_now).props("color=negative").classes(
"flex-1"
)
# === 日志区 ===
with ui.card().classes("func-card card-logging"):
with ui.expansion("📝 系统实时日志", value=False).classes(
"w-full bg-white shadow-sm rounded"
):
app_state.log_element = ui.log(max_lines=200).classes(
"w-full h-40 font-mono text-xs bg-gray-100 p-2"
)

213
ui/views/signature_page.py Normal file
View File

@@ -0,0 +1,213 @@
from nicegui import ui
import os
from config.config import load_config
from utils.file_utils import open_folder
from loguru import logger
import traceback
from pptx import Presentation
def create_signature_page(folder: str = ""):
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
# 添加样式
ui.add_head_html(
"""
<style>
.file-list { max-height: 450px; overflow-y: auto; }
.file-list::-webkit-scrollbar {
width: 6px;
}
.file-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.file-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.file-list::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
.file-item { transition: background-color 0.2s ease; }
.file-item:hover { background-color: #f8f9fa; }
</style>
"""
)
with ui.header().classes("app-header items-center justify-between shadow-md"):
# 左侧:图标和标题
with ui.row().classes("items-center gap-2"):
ui.image("/assets/icon.ico").classes("w-8 h-8").props("fit=contain")
ui.label("尚城幼儿园成长报告助手").classes("text-xl font-bold")
# 右侧:署名 + 配置按钮
with ui.row().classes("items-center gap-4"):
ui.label("By 寒寒 | 这里的每一份评语都充满爱意").classes(
"text-xs opacity-90"
)
ui.button(icon="home", on_click=lambda: ui.navigate.to("/")).props(
"flat round color=white"
)
with ui.card().classes("w-full"):
ui.label("💴 园长签名").classes("section-title")
with ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("flex-1 items-center"):
ui.label("📁 当前目录:").classes(
"text-sm text-white border bg-[#2e8b57] p-2 rounded"
)
ui.label(f"{folder}").classes("text-sm text-gray-600")
with ui.row().classes("items-center"):
ui.button(
"💴 一键签名",
on_click=lambda: sign_all_files(folder),
).props().classes()
ui.button(
"📂 打开文件夹",
on_click=lambda: open_folder(folder),
).props("outline").classes()
ui.button(
"🔄 刷新数据",
on_click=lambda: for_file_list(list_card, folder),
).props("outline").classes()
list_card = ui.card().classes("w-full p-4 gap-4")
for_file_list(list_card, folder)
# 遍历目录
def for_file_list(list_card, folder):
# 清空旧内容
list_card.clear()
files = get_signature_files(folder)
with list_card:
ui.label(f"📄 找到 {len(files)} 个 PPT 文件").classes(
"text-sm text-gray-600 mb-4"
)
# 存储选中的文件
selected_files = []
def toggle_file(file_name):
if file_name in selected_files:
selected_files.remove(file_name)
else:
selected_files.append(file_name)
# 创建可滚动的文件列表
with ui.grid(columns=2).classes("file-list"):
for ppt_file in files:
with ui.row().classes(
"w-full justify-between items-center file-item p-3 rounded mb-2"
):
with ui.row().classes("flex-1 items-center"):
ui.checkbox(
on_change=lambda e, f=ppt_file: toggle_file(f)
).classes("mr-3")
ui.label(ppt_file).classes("flex-1 text-sm")
with ui.row().classes("items-center gap-2"):
# 打开文件按钮
ui.button(
"📂 打开",
on_click=lambda f=ppt_file: open_folder(
os.path.join(folder, f)
),
).props("outline").classes("text-xs")
# 签名按钮
ui.button(
"💴 签名",
on_click=lambda f=ppt_file: (
sign_file(folder, f),
ui.notify(f"签名完成: {f}", type="positive"),
),
).props("outline").classes("text-xs")
def get_signature_files(folder: str) -> list:
"""获取目录下所有 PPT 文件"""
if not os.path.exists(folder):
return []
files = []
for filename in os.listdir(folder):
if not filename.startswith(".") and filename.endswith(".pptx"):
files.append(filename)
return sorted(files)
def sign_file(folder, file_name: str):
"""
生成园长签名(不依赖占位符,直接在指定位置添加)
:param folder: PPT 文件所在目录
:param file_name: PPT 文件名称
"""
try:
# 1. 加载配置文件
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try:
# 2. 等待签名文件路径
file_path = os.path.join(folder, file_name)
logger.info(f"开始生成签名,PPT文件:{file_name}")
# 加载签名图片路径
img_path = config.get("signature_image")
if not img_path or not os.path.exists(img_path):
logger.error(f"签名图片不存在: {img_path}")
logger.warning(f"⚠️ 警告: 缺少签名照片('signature'")
return
logger.info(f"签名图片存在: {img_path}")
# 从配置文件获取签名位置信息,如果没有则使用默认值
signature_left = config.get("signature_left", 2987040) # 左位置
signature_top = config.get("signature_top", 8273415) # 上位置
signature_width = config.get("signature_width", 1800000) # 宽度
signature_height = config.get("signature_height", 720000) # 高度
# 导入必要的模块
from utils.image_utils import get_corrected_image_stream
# 打开 PPT 对象
prs = Presentation(file_path)
# 获取第二张幻灯片 (索引为1)
slide = prs.slides[1]
# 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path)
# 直接在指定位置添加签名图片
slide.shapes.add_picture(
img_stream,
signature_left,
signature_top,
signature_width,
signature_height,
)
# 保存修改后的 PPT
prs.save(file_path)
logger.info(f"签名完成,PPT文件:{file_name}")
except Exception as e:
logger.error(f"generate_signature 发生未知错误: {e}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
return str(e)
def sign_selected_files(folder: str, files: list):
"""为选中的文件进行签名"""
total_files = len(files)
logger.info(f"开始生成签名,共 {total_files} 个文件")
for i, file_name in enumerate(files):
sign_file(folder, file_name)
logger.info(f"已为 {total_files} 个文件签名")
ui.notify(f"签名完成: {total_files} 个文件", type="positive")
def sign_all_files(folder: str):
"""为目录下所有文件进行签名"""
files = get_signature_files(folder)
if files:
sign_selected_files(folder, files)
total_files = len(files)
ui.notify(f"签名完成: {total_files} 个文件", type="positive")

View File

@@ -4,11 +4,14 @@ from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from loguru import logger
import traceback
from config.config import load_config
config = load_config("config.toml")
class_type_config =[
"本期开展了小袋鼠整合主题课程:(语言、社会、科学、健康、艺术)、生活数学;特色课程(英语、体能、美工、篮球)。",
"本学期开展了柏克莱主题课程(语言、社会、科学、艺术、健康);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。",
"本学期开展了双木桥主题课程(图说汉字、妙趣汉音、情智阅读、麦斯思维、专注力训练);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。"
]
def generate_comment(name, age_group, traits,sex):
"""
@@ -19,7 +22,14 @@ def generate_comment(name, age_group, traits,sex):
:param sex: 性别
:return: 评语
"""
# 1. 加载配置文件
try:
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
return "配置文件加载失败,请检查文件路径和内容。"
ai_config = config["ai"]
llm = ChatOpenAI(
base_url=ai_config["api_url"],
@@ -42,7 +52,8 @@ def generate_comment(name, age_group, traits,sex):
"name": name,
"age_group": age_group,
"traits": traits,
"sex": sex
"sex": sex,
"class_type": class_type_config[(config.get("class_type", 0))],
})
cleaned_text = re.sub(r'\s+', '', comment)
logger.success(f"学生:{name} =>生成评语成功: {cleaned_text}")

View File

@@ -3,15 +3,18 @@ import os
import time
from loguru import logger
import zipfile
import traceback
def export_templates_folder(output_folder="backup"):
def export_templates_folder(output_folder, stop_event, progress_callback=None):
"""
将指定文件夹压缩为 zip 包
:param source_folder: 要压缩的文件夹路径 (默认 'data')
:param output_folder: 压缩包存放的文件夹路径 (默认 'backup')
:param stop_event: 停止事件
:param progress_callback : 进度条回调
"""
source_folder = "data"
output_folder = output_folder if output_folder else "backup"
try:
# 1. 检查源文件夹是否存在
if not os.path.exists(source_folder):
@@ -55,11 +58,12 @@ def export_templates_folder(output_folder="backup"):
logger.error(traceback.format_exc())
def export_data(save_dir, root_dir="."):
def export_data(save_dir, root_dir=".", progress_callback=None):
"""
导出 data 和 output 两个文件夹到同一个 zip 包中
:param save_dir: 用户在 GUI 弹窗中选择的保存目录 (例如: D:/Backup)
:param root_dir: 项目根目录 (用于找到 data 和 output)
:param progress_callback: 进度条回调函数,接收一个 float (0.0~1.0)
"""
# 1. 定义要打包的目标文件夹
@@ -68,7 +72,18 @@ def export_data(save_dir, root_dir="."):
# 2. 检查保存目录
if not os.path.exists(save_dir):
logger.error(f"保存目录不存在: {save_dir}")
return
return None
# --- 【新增步骤 A】预先计算文件总数 ---
total_files = 0
for target in targets:
target_abs_path = os.path.join(root_dir, target)
if os.path.exists(target_abs_path):
for _, _, files in os.walk(target_abs_path):
total_files += len(files)
logger.info(f"待压缩文件总数: {total_files}")
# ------------------------------------
# 3. 生成压缩包路径
timestamp = time.strftime("%Y%m%d_%H%M%S")
@@ -78,15 +93,15 @@ def export_data(save_dir, root_dir="."):
logger.info(f"开始备份,目标文件: {zip_path}")
try:
# 4. 创建压缩包 (使用 'w' 写入模式ZIP_DEFLATED 表示压缩)
# 4. 创建压缩包
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
processed_count = 0 # 当前处理的文件数
has_files = False # 标记是否真的压缩了文件
for target in targets:
target_abs_path = os.path.join(root_dir, target)
# 检查 data 或 output 是否存在
if not os.path.exists(target_abs_path):
logger.warning(f"⚠️ 跳过: 找不到文件夹 '{target}'")
continue
@@ -94,26 +109,32 @@ def export_data(save_dir, root_dir="."):
logger.info(f"正在压缩: {target} ...")
# 5. 遍历文件夹写入 ZIP
# os.walk 会递归遍历子文件夹
for root, dirs, files in os.walk(target_abs_path):
for file in files:
# 获取文件的绝对路径
file_abs_path = os.path.join(root, file)
# 【关键】计算在压缩包里的相对路径
# 例如: D:/Project/data/images/1.jpg -> data/images/1.jpg
# 计算相对路径
arcname = os.path.relpath(file_abs_path, root_dir)
# 写入压缩包
zf.write(file_abs_path, arcname)
has_files = True
# 更新进度条
if progress_callback:
progress_callback(
processed_count + 1, total_files, "导出数据中..."
)
if has_files:
# 确保进度条最后能走到 100%
if progress_callback:
progress_callback(total_files, total_files, "导出数据成功")
logger.success(f"✅ 备份成功! 文件已保存至:\n{zip_path}")
return zip_path
else:
logger.error("❌ 备份失败: data 和 output 文件夹均为空或不存在。")
# 如果生成了空文件,建议删除
if os.path.exists(zip_path):
os.remove(zip_path)
return None
@@ -123,12 +144,14 @@ def export_data(save_dir, root_dir="."):
import traceback
logger.error(traceback.format_exc())
return None
def initialize_project(root_dir="."):
def initialize_project(root_dir=".", progress_callback=None):
"""
初始化项目:清空 data重建目录复制模板
:param root_dir: 项目根目录
:param progress_callback : 进度条回调
"""
# 定义路径
data_dir = os.path.join(root_dir, "data")
@@ -191,3 +214,49 @@ def initialize_project(root_dir="."):
logger.warning(
f"⚠️ 警告: 模板文件不存在 ({src_excel})data 文件夹内将没有 Excel 文件。"
)
def check_file_exists(file_path):
"""
判断文件是否存在
"""
return file_path and isinstance(file_path, str) and os.path.exists(file_path)
def get_output_pptx_files(output_dir="output"):
"""
获取 output 文件夹下所有的 pptx 文件
:param output_dir: output 文件夹路径
"""
try:
folder_path = os.path.abspath(output_dir)
if not os.path.exists(folder_path):
logger.error(f"文件夹不存在: {folder_path}")
return
# 获取所有 ppt/pptx 文件
files = [
f for f in os.listdir(folder_path) if f.lower().endswith((".ppt", ".pptx"))
]
if not files:
logger.warning("没有找到 PPT 文件")
return
total_count = len(files)
logger.info(f"发现 {total_count} 个文件,准备开始转换...")
return files
except Exception as e:
logger.error(f"发生未知错误: {e}")
logger.error(traceback.format_exc())
def open_folder(folder_path):
"""
打开指定文件夹
:param folder_path: 文件夹路径
"""
try:
if os.path.exists(folder_path):
os.startfile(folder_path)
else:
logger.error(f"文件夹不存在: {folder_path}")
except Exception as e:
logger.error(f"打开文件夹失败: {e}")

View File

@@ -4,8 +4,15 @@
import os
import platform
import shutil
import time
from pathlib import Path
from loguru import logger
from config.config import load_config
config = load_config("config.toml")
def get_system_fonts():
"""获取系统中可用的字体列表"""
@@ -21,7 +28,7 @@ def get_system_fonts():
for font_file in folder.glob(ext):
fonts.add(font_file.stem)
except Exception as e:
print(f"读取系统字体时出错: {e}")
logger.error(f"读取系统字体时出错: {e}")
fonts = {"微软雅黑", "宋体", "黑体", "Arial", "Microsoft YaHei"}
return fonts
@@ -40,14 +47,14 @@ def is_font_available(font_name):
def install_fonts_from_directory(fonts_dir="fonts"):
"""从指定目录安装字体到系统"""
if platform.system() != "Windows":
print("字体安装功能目前仅支持Windows系统")
logger.success("字体安装功能目前仅支持Windows系统")
return False
target_font_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
target_font_dir.mkdir(parents=True, exist_ok=True)
if not os.path.exists(fonts_dir):
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
logger.error(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
return False
font_files = []
@@ -55,7 +62,7 @@ def install_fonts_from_directory(fonts_dir="fonts"):
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
if not font_files:
print(f"{fonts_dir} 目录中未找到字体文件")
logger.error(f"{fonts_dir} 目录中未找到字体文件")
return False
installed_count = 0
@@ -64,12 +71,12 @@ def install_fonts_from_directory(fonts_dir="fonts"):
target_path = target_font_dir / font_file.name
if not target_path.exists():
shutil.copy2(font_file, target_path)
print(f"已安装字体: {font_file.name}")
logger.success(f"已安装字体: {font_file.name}")
installed_count += 1
except Exception as e:
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
logger.error(f"安装字体 {font_file.name} 时出错: {str(e)}")
if installed_count > 0:
print(f"共安装了 {installed_count} 个新字体文件建议重启Python环境")
logger.success(f"共安装了 {installed_count} 个新字体文件建议重启Python环境")
return True
return False

View File

@@ -1,4 +1,5 @@
import os
import threading
import time
import pythoncom
@@ -6,11 +7,12 @@ import pandas as pd
from loguru import logger
from pptx import Presentation
from rich.console import Console
import traceback
import comtypes.client
from config.config import load_config
from utils.agent_utils import generate_comment
from utils.font_utils import install_fonts_from_directory
from utils.file_utils import check_file_exists, get_output_pptx_files
from utils.image_utils import find_image_path
from utils.zodiac_utils import calculate_zodiac
from utils.growt_utils import (
@@ -22,38 +24,43 @@ from utils.growt_utils import (
)
# 如果你之前没有全局定义 console这里定义一个
console = Console()
# ==========================================
# 1. 配置区域 (Configuration)
# ==========================================
config = load_config("config.toml")
# ==========================================
# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹)
# ==========================================
def generate_template():
def generate_template(stop_event: threading.Event = None, progress_callback=None):
""" "
根据学生姓名生成相对应的以学生姓名的存放照片的文件夹
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
"""
# 1. 加载配置文件
try:
# 2. 读取数据
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try:
# 1. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
# --- 修改点开始 ---
# 直接读取 "姓名" 这一列,不使用列表包裹列名,这样得到的是一维数据
# 2. 获取姓名数据
datas = df["姓名"].values.tolist()
# --- 修改点结束 ---
logger.info(f"开始生成学生模版文件,共 {len(datas)} 位学生...")
total_count = len(datas)
logger.info(f"开始生成学生模版文件,共 {total_count} 位学生...")
# 3. 循环处理
# 此时 name 就是字符串 '张三',而不是列表 ['张三']
for i, name in enumerate(datas):
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 判断是否有停止事件
if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....")
return # 停止任务
# 添加进度条
if progress_callback:
progress_callback(i + 1, total_count, "生成学生图片文件夹")
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 确保 name 是字符串且去除了空格 (增加健壮性)
name = str(name).strip()
# 生成学生图片的文件夹
student_folder = os.path.join(config["image_folder"], name)
if os.path.exists(student_folder):
@@ -61,19 +68,32 @@ def generate_template():
else:
logger.info(f"正在生成学生图片文件夹 {student_folder}")
os.makedirs(student_folder, exist_ok=True)
# 更新进度条为100%
if progress_callback:
progress_callback(total_count, total_count, "生成学生图片文件夹")
logger.success("✅ 所有学生模版文件已生成完毕")
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 2. 生成评语(根据names.xlsx文件生成评价)
# ==========================================
def generate_comment_all():
def generate_comment_all(stop_event: threading.Event = None, progress_callback=None):
"""
根据学生姓名生成评价
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
"""
# 1. 加载配置文件
try:
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try:
# 1. 读取数据
excel_path = config["excel_file"]
@@ -83,26 +103,26 @@ def generate_comment_all():
if "评价" not in df.columns:
df["评价"] = ""
# --- 获取总行数,用于日志 ---
# 强制将“评价”列转换为 object 类型
# 获取学生数据行数
total_count = len(df)
logger.info(f"开始生成学生评语,共 {total_count} 位学生...")
logger.info(f"开始生成学生评语,共 {len(df)} 位学生...")
# 强制将“评价”列转换为 object 类型
df["评价"] = df["评价"].astype("object")
# --- 遍历 DataFrame 的索引 (index) ---
# 这样我们可以通过索引 i 精准地把数据写回某一行
for i in df.index:
name = df.at[i, "姓名"] # 获取当前行的姓名
sex = df.at[i, "性别"]
if pd.isna(sex):
sex = ""
else:
sex = str(sex).strip()
# 健壮性处理
if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....")
return # 停止任务
# 获取学生姓名
name = df.at[i, "姓名"]
if pd.isna(name):
continue # 跳过空行
name = str(name).strip()
continue
else:
name = str(name).strip()
# 获取性别
sex = pd.isna(df.at[i, "性别"]) if "" else str(df.at[i, "性别"]).strip()
# 获取当前行的特征如果Excel里有“特征”这一列就读没有就用默认值
# 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..."
@@ -117,68 +137,69 @@ def generate_comment_all():
if not pd.isna(current_comment) and str(current_comment).strip() != "":
logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。")
continue
# 添加进度条
if progress_callback:
progress_callback(
i + 1, total_count, f"[{i + 1}/{total_count}] 正在生成评价: {name}"
)
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
try:
# 调用你的生成函数,并【接收返回值】
# 注意:这里假设 generate_comment 返回的是清洗后的字符串
# 调用AI大模型生成内容
generated_text = generate_comment(
name, config["age_group"], traits, sex
)
# --- 将结果写入 DataFrame ---
df.at[i, "评价"] = generated_text
df.at[i, "评价"] = generated_text if str(generated_text).strip() else ""
logger.success(f"学生:{name},评语生成完毕")
# 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失
# 可选:每生成 5 个就保存一次
if (i + 1) % 5 == 0:
df.to_excel(excel_path, index=False)
logger.info("--- 阶段性保存成功 ---")
time.sleep(1) # 避免触发API速率限制
logger.success(" 阶段性保存成功")
# 避免触发API速率限制
time.sleep(1)
except Exception as e:
logger.error(f"学生:{name},生成评语出错: {str(e)}")
# --- 修改点 4: 循环结束后最终保存文件 ---
# --- 循环结束后最终保存文件 ---
# index=False 表示不把 pandas 的索引 (0,1,2...) 写到 Excel 第一列
df.to_excel(excel_path, index=False)
logger.success(f"所有评语已生成并写入文件:{excel_path}")
if progress_callback:
progress_callback(total_count, total_count, "生成学生评语")
except PermissionError:
logger.error(f"保存失败!请先关闭 Excel 文件:{config['excel_file']}")
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 3. 生成成长报告(根据names.xlsx文件生成)
# ==========================================
def generate_report():
# 1. 资源准备
if install_fonts_from_directory(config["fonts_dir"]):
logger.info("等待系统识别新安装的字体...")
time.sleep(2)
os.makedirs(config["output_folder"], exist_ok=True)
# 检查模版文件是否存在
def generate_report(stop_event: threading.Event = None, progress_callback=None):
"""
根据学生姓名生成成长报告
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
""" # 1. 加载配置文件
try:
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
# 1. 检查模版文件是否存在
if not os.path.exists(config["source_file"]):
logger.info(f"错误: 找不到模版文件 {config["source_file"]}")
return
# 检查数据文件是否存在
# 2. 检查数据文件是否存在
if not os.path.exists(config["excel_file"]):
logger.info(f"错误: 找不到数据文件 {config['excel_file']}")
return
try:
# 2. 读取数据
# 1. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
# 确保列名对应
# 2. 确保列名对应
columns = [
"姓名",
"英文名",
@@ -191,14 +212,18 @@ def generate_report():
"喜欢吃的食物",
"评价",
]
# 获取数据列表
datas = df[columns].values.tolist()
total_count = len(datas)
# 获取配置文件的教师签名
teacher_names_str = " ".join(config["teachers"])
logger.info(f"开始处理,共 {len(datas)} 位学生...")
logger.info(f"开始处理,共 {total_count} 位学生...")
# 3. 循环处理
for i, row_data in enumerate(datas):
if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....")
return
# 解包数据
(
name,
@@ -212,7 +237,13 @@ def generate_report():
food,
comments,
) = row_data
# 更新进度条
if progress_callback:
progress_callback(
i + 1,
total_count,
f"[{i + 1}/{len(datas)}] 正在生成: 【{name}】 成长报告",
)
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 每次循环重新加载模版
@@ -227,43 +258,67 @@ def generate_report():
# --- 页面 3 ---
student_image_folder = os.path.join(config["image_folder"], name)
logger.info(f"学生:{name},图片文件夹: {student_image_folder}")
if os.path.exists(student_image_folder):
me_image_path = find_image_path(student_image_folder, "me")
# 判断学生图片文件夹是否存在
if not os.path.exists(student_image_folder):
logger.warning(
f"⚠️ 警告: 学生:{name},学生图片文件夹不存在 {student_image_folder}"
)
continue
# 构造信息字典供 helper 使用
info_dict = {
"name": name,
"english_name": english_name,
"sex": sex,
"birthday": birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else "",
"zodiac": zodiac,
"friend": friend,
"hobby": hobby,
"game": game,
"food": food,
}
# 逻辑:必须同时满足 "不是None" 且 "是字符串" 且 "文件存在" 才能执行
if (
me_image_path
and isinstance(me_image_path, str)
and os.path.exists(me_image_path)
):
replace_three_page(prs, info_dict, me_image_path)
else:
# 只有在这里打印日志,告诉用户跳过了,但不中断程序
replace_three_page(prs, info_dict, None)
# 检查姓名是否为空
if not name:
logger.error(f"⚠️ 警告: 学生:{name},姓名为空,跳过")
break
# 构造学生信息字典
student_info_dict = {
"name": name,
"english_name": english_name if pd.notna(english_name) else " ",
"sex": sex if pd.notna(sex) else "",
"birthday": (
birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else " "
),
"zodiac": (
str(zodiac).strip()
if str(zodiac).strip() or not str(zodiac).strip().lower()
else " "
),
"friend": (
str(friend).strip()
if str(friend).strip() or not str(friend).strip().lower()
else " "
),
"hobby": (
str(hobby).strip()
if str(hobby).strip() or not str(hobby).strip().lower()
else " "
),
"game": (
str(game).strip()
if str(game).strip() or not str(game).strip().lower()
else " "
),
"food": (
str(food).strip()
if str(food).strip() or not str(food).strip().lower()
else " "
),
}
# 获取学生个人照片路径
me_image_path = find_image_path(student_image_folder, "me")
# 检查学生图片是否存在,若不存在则跳过
if check_file_exists(me_image_path):
replace_three_page(prs, student_info_dict, me_image_path)
else:
logger.warning(f"⚠️ 警告: 学生:{name},学生图片文件夹不存在 {student_image_folder}")
replace_three_page(prs, student_info_dict)
logger.warning(f"⚠️ 警告: 学生图片文件不存在 {me_image_path}")
# --- 页面 4 ---
class_image_path = find_image_path(
config["image_folder"], config["class_name"]
)
if (
class_image_path
and isinstance(class_image_path, str)
and os.path.exists(class_image_path)
):
# 添加检查班级图片是否存在,若不存在则跳过
if check_file_exists(class_image_path):
replace_four_page(prs, class_image_path)
else:
logger.warning(f"⚠️ 警告: 班级图片文件不存在 {class_image_path}")
@@ -302,29 +357,36 @@ def generate_report():
f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。"
)
if progress_callback:
progress_callback(total_count, total_count, "生成报告")
logger.success("所有报告生成完毕!")
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 5. 转换格式(根据names.xlsx文件生成PPT转PDF)
# 4. 转换格式(根据names.xlsx文件生成PPT转PDF)
# ==========================================
def batch_convert_folder(folder_path):
def generate_convert_pdf(stop_event: threading.Event = None, progress_callback=None):
"""
【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint速度快)
已修复多线程 CoInitialize 报错,并适配 GUI 日志
批量转换文件夹下的所有 PPT
:params folder_path 需要转换的PPT文件夹
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
"""
# 【核心修复 1】子线程初始化 COM 组
pythoncom.CoInitialize()
# 1. 加载配置文
try:
folder_path = os.path.abspath(folder_path)
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
# 子线程初始化 COM 组件
pythoncom.CoInitialize()
try:
folder_path = os.path.abspath(config.get("output_folder"))
if not os.path.exists(folder_path):
logger.error(f"文件夹不存在: {folder_path}")
return
@@ -338,18 +400,24 @@ def batch_convert_folder(folder_path):
logger.warning("没有找到 PPT 文件")
return
logger.info(f"发现 {len(files)} 个文件,准备开始转换...")
total_count = len(files)
logger.info(f"发现 {total_count} 个文件,准备开始转换...")
powerpoint = None
try:
# 1. 启动应用 (只启动一次)
powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
# 【建议】在后台线程运行时,有时设置为不可见更稳定,
# 但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来
# 设置是否显示转化页面,但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来
# powerpoint.Visible = 1
for filename in files:
if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....")
return
# 添加进度条
if progress_callback:
progress_callback(files.index(filename), total_count, "转换PDF")
ppt_path = os.path.join(folder_path, filename)
pdf_path = os.path.splitext(ppt_path)[0] + ".pdf"
@@ -358,16 +426,21 @@ def batch_convert_folder(folder_path):
logger.info(f"[跳过] 已存在: {filename}")
continue
logger.info(f"正在转换: {filename} ...")
logger.info(
f"[{files.index(filename)}/{total_count}]正在转换: {filename} ..."
)
try:
# 打开 -> 另存为 -> 关闭
deck = powerpoint.Presentations.Open(ppt_path)
deck.SaveAs(pdf_path, 32) # 32 代表 PDF 格式
deck.SaveAs(pdf_path, 32) # 32 代表 PDF 格式
deck.Close()
except Exception as e:
logger.error(f"文件 {filename} 转换出错: {e}")
# 添加进度条
if progress_callback:
progress_callback(total_count, total_count, "转换PDF")
except Exception as e:
logger.error(f"PowerPoint 进程启动出错: {e}")
finally:
@@ -385,14 +458,26 @@ def batch_convert_folder(folder_path):
# 【核心修复 2】释放资源
pythoncom.CoUninitialize()
# ==========================================
# 5. 生成属相(根据names.xlsx文件生成属相)
# ==========================================
def generate_zodiac():
def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
"""
生成学生属相,如果“生日”列为空,则跳过该学生。
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
"""
# 1. 加载配置文件
try:
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try:
# 1. 读取数据
excel_path = config["excel_file"]
# sheet_name 根据实际情况修改,如果不确定可以用 sheet_name=0 读取第一个
df = pd.read_excel(excel_path, sheet_name="Sheet1")
# 2. 检查必要的列
@@ -403,32 +488,145 @@ def generate_zodiac():
logger.error(f"Excel中找不到列名{date_column}】,请检查表头。")
return
# 检查是否存在"属相"列,不存在则新建
if target_column not in df.columns:
df[target_column] = ""
# --- 获取总行数,用于日志 ---
total_count = len(df)
logger.info(f"开始生成学生属相,共 {total_count} 位学生...")
# 3. 数据清洗与计算
temp_dates = pd.to_datetime(df[date_column], errors="coerce")
df[target_column] = temp_dates.apply(calculate_zodiac)
# 3. 预处理:将“生日”列转换为 datetime 格式
df["temp_date"] = pd.to_datetime(df[date_column], errors="coerce")
# 4. 遍历 DataFrame 并计算/更新数据
for i, row in df.iterrows():
# 关键点 1: 检查停止信号
if stop_event and stop_event.is_set():
logger.warning("任务已接收到停止信号,正在中断...")
return
# 添加进度条
if progress_callback:
progress_callback(i + 1, total_count, "生成属相")
name = row.get("姓名", f"学生_{i + 1}")
date = row["temp_date"]
logger.info(f"[{i + 1}/{total_count}] 正在处理学生:{name}...")
# === 关键点 2: 检查生日是否为空 ===
if pd.isna(date):
# 记录警告日志并跳过当前循环迭代
logger.warning(f"跳过:学生【{name}】的生日数据为空或格式错误。")
# 可以选择将属相字段清空或设置为特定值,此处设置为“待补充”
df.loc[i, target_column] = "待补充"
continue # 跳到下一个学生
# =================================
# 5. 计算并赋值
zodiac = calculate_zodiac(date)
df.loc[i, target_column] = zodiac
logger.info(f" -> 属相计算成功:{name} ,属相: {zodiac}")
# 6. 清理和保存结果
df = df.drop(columns=["temp_date"])
# 5. 保存结果
save_path = excel_path
try:
df.to_excel(save_path, index=False)
df.to_excel(save_path, sheet_name="Sheet1", index=False)
logger.success(f"所有属相已更新并写入文件:{save_path}")
logger.warning(f"请检查文件 {save_path} 修改日期格式。")
except PermissionError:
logger.error(f"保存失败!请先关闭 Excel 文件:{save_path}")
# 添加进度条
if progress_callback:
progress_callback(total_count, total_count, "生成属相")
except FileNotFoundError:
logger.error(f"找不到文件 {config.get('excel_file')}")
logger.error(traceback.format_exc())
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 6. 一键生成园长签名(根据输出文件夹生成签名)
# ==========================================
def generate_signature(progress_callback=None) -> str:
"""
生成园长签名(不依赖占位符,直接在指定位置添加)
"""
# 1. 加载配置文件
try:
config = load_config("config.toml")
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try:
# 获取所有的PPT (此时返回的是文件名或路径的列表)
pptx_files = get_output_pptx_files(config["output_folder"])
if not pptx_files:
logger.warning("没有找到 PPT 文件")
return "未找到文件"
logger.info(f"开始生成签名,共 {len(pptx_files)} 个 PPT 文件...")
img_path = config.get("signature_image") # 签名图片路径
if not img_path or not os.path.exists(img_path):
logger.error(f"签名图片不存在: {img_path}")
logger.warning(f"⚠️ 警告: 缺少签名照片('signature'")
return
logger.info(f"签名图片存在: {img_path}")
# 从配置文件获取签名位置信息,如果没有则使用默认值
signature_left = config.get("signature_left", 2987040) # 左位置
signature_top = config.get("signature_top", 8273415) # 上位置
signature_width = config.get("signature_width", 1800000) # 宽度
signature_height = config.get("signature_height", 720000) # 高度
# 导入必要的模块
from utils.image_utils import get_corrected_image_stream
for i, filename in enumerate(pptx_files):
# 获取完整绝对路径
pptx_path = os.path.join(config["output_folder"], filename)
# 打开 PPT 对象
prs = Presentation(pptx_path)
# 获取第二张幻灯片 (索引为1)
slide = prs.slides[1]
# 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path)
# 直接在指定位置添加签名图片
slide.shapes.add_picture(
img_stream,
signature_left,
signature_top,
signature_width,
signature_height,
)
logger.info(f"在幻灯片 1 上添加签名图片")
# 保存修改后的 PPT
prs.save(pptx_path)
# 更新进度条 (如果有 callback)
if progress_callback:
progress_callback(
i + 1,
len(pptx_files),
f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}",
)
logger.success(f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}")
if progress_callback:
progress_callback(len(pptx_files), len(pptx_files), "签名生成完成")
except Exception as e:
logger.error(f"generate_signature 发生未知错误: {e}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
return str(e)

View File

@@ -11,6 +11,8 @@ console = Console()
# 1. 配置区域 (Configuration)
# ==========================================
config = load_config("config.toml")
def replace_one_page(prs, name, class_name):
"""替换第一页信息"""
replace_text_in_slide(prs, 0, "name", name)
@@ -23,7 +25,7 @@ def replace_two_page(prs, comments, teacher_name):
replace_text_in_slide(prs, 1, "teacher_name", teacher_name)
def replace_three_page(prs, info_dict, me_image):
def replace_three_page(prs, info_dict, me_image=None):
"""替换第三页信息"""
# 使用字典解包传递多个字段,减少参数数量
fields = ["name", "english_name", "sex", "birthday", "zodiac", "friend", "hobby", "game", "food"]

View File

@@ -1,4 +1,6 @@
import os
from PIL import Image, ExifTags
import io
def find_image_path(folder, base_filename):
@@ -26,3 +28,39 @@ def find_image_path(folder, base_filename):
return full_path
return None
def get_corrected_image_stream(img_path):
"""
读取图片,根据 EXIF 信息修正旋转方向,并返回 BytesIO 对象。
这样不需要修改原文件,直接在内存中处理。
"""
image = Image.open(img_path)
# 获取 EXIF 数据
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = dict(image._getexif().items())
if exif[orientation] == 3:
image = image.rotate(180, expand=True)
elif exif[orientation] == 6:
image = image.rotate(270, expand=True)
elif exif[orientation] == 8:
image = image.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
# 如果图片没有 EXIF 数据或不需要旋转,则忽略
pass
# 将处理后的图片保存到内存流中
image_stream = io.BytesIO()
# 注意:保存时保持原格式,如果是 PNG 等无 EXIF 的格式会自动处理
img_format = image.format if image.format else 'JPEG'
image.save(image_stream, format=img_format)
image_stream.seek(0) # 指针回到开头
return image_stream

27
utils/log_handler.py Normal file
View File

@@ -0,0 +1,27 @@
import queue
import re
from loguru import logger
# 全局日志队列
log_queue = queue.Queue()
def ansi_cleaner(text):
"""去除 loguru 输出中的颜色代码,防止在 UI 显示乱码"""
ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
return ansi_escape.sub("", text)
def queue_sink(message):
"""Loguru 的回调函数"""
clean_msg = ansi_cleaner(message)
log_queue.put(clean_msg)
def setup_logging():
"""配置日志系统"""
# 清除默认的控制台输出,防止干扰
logger.remove()
# 添加队列输出 (给 UI 用)
logger.add(queue_sink, format="{time:HH:mm:ss} | {level: <8} | {message}", level="INFO")
# 添加文件输出 (给开发者排查用)
# logger.add("logs/app_runtime.log", rotation="1 MB", encoding="utf-8", level="DEBUG")

View File

@@ -1,5 +1,5 @@
import os
import pythoncom
import comtypes.client
@@ -9,6 +9,8 @@ def ppt_to_pdf_single(ppt_path, pdf_path=None):
:param ppt_path: PPT 文件路径
:param pdf_path: PDF 输出路径 (可选,默认同名)
"""
# 子线程初始化 COM 组件
pythoncom.CoInitialize()
ppt_path = os.path.abspath(ppt_path) # COM 接口必须使用绝对路径
if pdf_path is None:
@@ -40,3 +42,4 @@ def ppt_to_pdf_single(ppt_path, pdf_path=None):
finally:
if powerpoint:
powerpoint.Quit()
pythoncom.CoUninitialize()

View File

@@ -3,7 +3,10 @@
# ==========================================
import os
from loguru import logger
from utils.font_utils import is_font_available
from utils.image_utils import get_corrected_image_stream
def replace_text_in_slide(prs, slide_index, placeholder, text):
@@ -18,28 +21,28 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
original_paragraph_formats = []
for paragraph in shape.text_frame.paragraphs:
paragraph_format = {
'alignment': paragraph.alignment,
'space_before': getattr(paragraph, 'space_before', None),
'space_after': getattr(paragraph, 'space_after', None),
'line_spacing': getattr(paragraph, 'line_spacing', None),
'left_indent': getattr(paragraph, 'left_indent', None),
'right_indent': getattr(paragraph, 'right_indent', None),
'first_line_indent': getattr(paragraph, 'first_line_indent', None),
'font_info': []
"alignment": paragraph.alignment,
"space_before": getattr(paragraph, "space_before", None),
"space_after": getattr(paragraph, "space_after", None),
"line_spacing": getattr(paragraph, "line_spacing", None),
"left_indent": getattr(paragraph, "left_indent", None),
"right_indent": getattr(paragraph, "right_indent", None),
"first_line_indent": getattr(paragraph, "first_line_indent", None),
"font_info": [],
}
for run in paragraph.runs:
run_format = {
'font_name': run.font.name,
'font_size': run.font.size,
'bold': run.font.bold,
'italic': run.font.italic,
'underline': run.font.underline,
'color': run.font.color,
'character_space': getattr(run.font, 'space', None),
'all_caps': getattr(run.font, 'all_caps', None),
'small_caps': getattr(run.font, 'small_caps', None)
"font_name": run.font.name,
"font_size": run.font.size,
"bold": run.font.bold,
"italic": run.font.italic,
"underline": run.font.underline,
"color": run.font.color,
"character_space": getattr(run.font, "space", None),
"all_caps": getattr(run.font, "all_caps", None),
"small_caps": getattr(run.font, "small_caps", None),
}
paragraph_format['font_info'].append(run_format)
paragraph_format["font_info"].append(run_format)
original_paragraph_formats.append(paragraph_format)
# 2. 设置新文本
@@ -48,64 +51,119 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
# 3. 恢复格式
for i, paragraph in enumerate(shape.text_frame.paragraphs):
orig_idx = i if i < len(original_paragraph_formats) else -1
if not original_paragraph_formats: break
if not original_paragraph_formats:
break
original_para = original_paragraph_formats[orig_idx]
# 恢复段落属性
for attr in ['alignment', 'space_before', 'space_after', 'line_spacing',
'left_indent', 'right_indent', 'first_line_indent']:
for attr in [
"alignment",
"space_before",
"space_after",
"line_spacing",
"left_indent",
"right_indent",
"first_line_indent",
]:
if original_para[attr] is not None:
setattr(paragraph, attr, original_para[attr])
# 恢复字体属性
for j, run in enumerate(paragraph.runs):
font_idx = j if j < len(original_para['font_info']) else 0
if not original_para['font_info']: break
font_idx = j if j < len(original_para["font_info"]) else 0
if not original_para["font_info"]:
break
original_font = original_para['font_info'][font_idx]
original_font = original_para["font_info"][font_idx]
# 字体名称检查与回退
if original_font['font_name']:
if is_font_available(original_font['font_name']):
run.font.name = original_font['font_name']
if original_font["font_name"]:
if is_font_available(original_font["font_name"]):
run.font.name = original_font["font_name"]
else:
run.font.name = "微软雅黑"
# 恢复其他字体属性
if original_font['font_size']: run.font.size = original_font['font_size']
if original_font['bold']: run.font.bold = original_font['bold']
if original_font['italic']: run.font.italic = original_font['italic']
if original_font['underline']: run.font.underline = original_font['underline']
if original_font['all_caps']: run.font.all_caps = original_font['all_caps']
if original_font["font_size"]:
run.font.size = original_font["font_size"]
if original_font["bold"]:
run.font.bold = original_font["bold"]
if original_font["italic"]:
run.font.italic = original_font["italic"]
if original_font["underline"]:
run.font.underline = original_font["underline"]
if original_font["all_caps"]:
run.font.all_caps = original_font["all_caps"]
if original_font['character_space']:
if original_font["character_space"]:
try:
run.font.space = original_font['character_space']
run.font.space = original_font["character_space"]
except:
pass
logger.error(
f"错误: 无法设置字体间距 {original_font['character_space']}"
)
if original_font['color']:
if original_font["color"]:
try:
if hasattr(original_font['color'], 'rgb'):
run.font.color.rgb = original_font['color'].rgb
if hasattr(original_font["color"], "rgb"):
run.font.color.rgb = original_font["color"].rgb
except:
pass
logger.error(
f"错误: 无法设置字体颜色 {original_font['color']}"
)
def replace_picture(prs, slide_index, placeholder, img_path):
"""在指定幻灯片中替换指定占位符的图片"""
"""
在指定幻灯片中替换指定占位符的图片(包含自动旋转修复)
参数:
prs: Presentation 对象
slide_index: 幻灯片索引 (从0开始)
placeholder: 占位符名称 (例如 "signature")
img_path: 图片路径
"""
if not os.path.exists(img_path):
print(f"警告: 图片路径不存在 {img_path}")
logger.warning(f"警告: 图片路径不存在 {img_path}")
return
slide = prs.slides[slide_index]
sp_tree = slide.shapes._spTree
target_shape = None
target_index = -1
# 1. 先找到目标形状和它的索引
for i, shape in enumerate(slide.shapes):
if shape.name == placeholder:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
sp_tree.remove(shape._element)
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
sp_tree.insert(i, new_shape._element)
target_shape = shape
target_index = i
break
if target_shape:
logger.debug(f"找到占位符 {placeholder},索引 {target_index}")
logger.debug(
f"占位符位置: 左 {target_shape.left}, 上 {target_shape.top}, 宽 {target_shape.width}, 高 {target_shape.height}"
)
# 获取原位置信息
left, top, width, height = (
target_shape.left,
target_shape.top,
target_shape.width,
target_shape.height,
)
# 2. 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path)
# 3. 移除旧形状
sp_tree.remove(target_shape._element)
# 4. 插入新图片 (使用流而不是路径)
new_shape = slide.shapes.add_picture(img_stream, left, top, width, height)
# 5. 恢复层级位置 (z-order)
sp_tree.insert(target_index, new_shape._element)
else:
logger.warning(f"警告: 幻灯片 {slide_index} 中未找到占位符 {placeholder}")

24
utils/template_utils.py Normal file
View File

@@ -0,0 +1,24 @@
import os
from config.config import get_base_dir
def get_template_files():
"""
遍历 templates 目录,返回所有 PPTX 文件的文件名列表
"""
# 获取 templates 文件夹的绝对路径
# 这里的 get_base_dir() 是你之前定义的函数
base_dir = get_base_dir()
templates_dir = os.path.join(base_dir, 'templates')
# 检查目录是否存在,不存在则返回空列表
if not os.path.exists(templates_dir):
return []
# 遍历目录
files = []
for filename in os.listdir(templates_dir):
# 过滤掉隐藏文件,并只保留 .pptx 结尾的文件
if not filename.startswith('.') and filename.endswith('.pptx'):
files.append(filename)
return sorted(files)

1185
uv.lock generated

File diff suppressed because it is too large Load Diff