Compare commits

..

14 Commits

34 changed files with 1549 additions and 902 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ output/*.pptx
output/*.pdf output/*.pdf
data/images/* data/images/*
data/*.xlsx data/*.xlsx
.idea/
.trae/

155
.idea/workspace.xml generated
View File

@@ -4,32 +4,43 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment=""> <list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="fix优化PDF转换逻辑">
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/ui/views/templates/back_home.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/growth_report.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/growth_report.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/config.toml" beforeDir="false" afterPath="$PROJECT_DIR$/config.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/config.py" beforeDir="false" afterPath="$PROJECT_DIR$/config/config.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/config/config.py" beforeDir="false" afterPath="$PROJECT_DIR$/config/config.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/names.xlsx" beforeDir="false" afterPath="$PROJECT_DIR$/data/names.xlsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/data/names.xlsx" beforeDir="false" afterPath="$PROJECT_DIR$/data/names.xlsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main_nicegui.py" beforeDir="false" afterPath="$PROJECT_DIR$/main_nicegui.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/config_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/config_page.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/ui/views/config_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/config_page.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/convert_pdf_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/convert_pdf_page.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/data_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/data_page.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/home_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/home_page.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/signature_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/signature_page.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/utils/font_utils.py" beforeDir="false" afterPath="$PROJECT_DIR$/utils/font_utils.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/utils/generate_utils.py" beforeDir="false" afterPath="$PROJECT_DIR$/utils/generate_utils.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/utils/growt_utils.py" beforeDir="false" afterPath="$PROJECT_DIR$/utils/growt_utils.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/uv.lock" beforeDir="false" afterPath="$PROJECT_DIR$/uv.lock" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Python Script" />
</list>
</option>
</component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
<component name="ProjectColorInfo"><![CDATA[{ <component name="ProjectColorInfo">{
"customColor": "", &quot;customColor&quot;: &quot;&quot;,
"associatedIndex": 0 &quot;associatedIndex&quot;: 0
}]]></component> }</component>
<component name="ProjectId" id="3744WiSuPrq64wZVLisMf4zKTFq" /> <component name="ProjectId" id="3744WiSuPrq64wZVLisMf4zKTFq" />
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
@@ -38,10 +49,12 @@
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent"><![CDATA[{
"keyToString": { "keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true", "ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.main.executor": "Run",
"Python.main_nicegui.executor": "Run", "Python.main_nicegui.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true", "RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true", "SHARE_PROJECT_CONFIGURATION_FILES": "true",
"git-widget-placeholder": "master", "git-widget-placeholder": "master",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
@@ -49,11 +62,35 @@
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)", "node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm", "nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable", "settings.editor.selected.configurable": "preferences.lookFeel",
"vue.rearranger.settings.migration": "true" "vue.rearranger.settings.migration": "true"
} }
}]]></component> }]]></component>
<component name="RunManager"> <component name="RunManager" selected="Python.main">
<configuration name="main" 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.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>
<configuration name="main_nicegui" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true"> <configuration name="main_nicegui" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="growth_report" /> <module name="growth_report" />
<option name="ENV_FILES" value="" /> <option name="ENV_FILES" value="" />
@@ -68,6 +105,7 @@
<option name="ADD_CONTENT_ROOTS" value="true" /> <option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" /> <option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" /> <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main_nicegui.py" /> <option name="SCRIPT_NAME" value="$PROJECT_DIR$/main_nicegui.py" />
<option name="PARAMETERS" value="" /> <option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" /> <option name="SHOW_COMMAND_LINE" value="false" />
@@ -79,10 +117,19 @@
</configuration> </configuration>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="Python.main" />
<item itemvalue="Python.main_nicegui" /> <item itemvalue="Python.main_nicegui" />
</list> </list>
</recent_temporary> </recent_temporary>
</component> </component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-PY-253.29346.308" />
<option value="bundled-python-sdk-ca5e2b39c7df-6e1f45a539f7-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.29346.308" />
</set>
</attachedChunks>
</component>
<component name="TaskManager"> <component name="TaskManager">
<task active="true" id="Default" summary="默认任务"> <task active="true" id="Default" summary="默认任务">
<changelist id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="" /> <changelist id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="" />
@@ -90,14 +137,88 @@
<option name="number" value="Default" /> <option name="number" value="Default" />
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1766149044347</updated> <updated>1766149044347</updated>
<workItem from="1766149046808" duration="2127000" /> <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" />
<workItem from="1769696481591" duration="57000" />
<workItem from="1769696548056" duration="6079000" />
</task> </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>
<task id="LOCAL-00005" summary="fix优化PDF转换逻辑">
<option name="closed" value="true" />
<created>1769699406868</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1769699406868</updated>
</task>
<option name="localTasksCounter" value="6" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" /> <option name="version" value="3" />
</component> </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优化一些命名规范" />
<MESSAGE value="fix优化PDF转换逻辑" />
<option name="LAST_COMMIT_MESSAGE" value="fix优化PDF转换逻辑" />
<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"> <component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/growth_report$main_nicegui.coverage" NAME="main_nicegui 覆盖结果" MODIFIED="1766150538951" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" /> <SUITE FILE_PATH="coverage/growth_report$main.coverage" NAME="main 覆盖结果" MODIFIED="1769703398756" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<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> </component>
</project> </project>

View File

@@ -0,0 +1,25 @@
{
"data_folder": "D:\\working\\tools\\growth_report\\data",
"templates_folder": "D:\\working\\tools\\growth_report\\templates",
"source_file": "大四班 幼儿学期发展报告.pptx",
"output_folder": "output",
"excel_file": "names.xlsx",
"image_folder": "images",
"fonts_dir": "fonts",
"signature_image": "signature.png",
"class_name": "K4D",
"teachers": [
"康璐璐",
"冯宇阳",
"孙继艳"
],
"class_type": 2,
"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"
}
}

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模板中最终生成每个学生的个性化成长报告。系统支持双界面运行命令行界面、图形界面和NiceGUI界面具备字体安装、图片替换、批量PDF转换、生肖计算功能。 基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持UI界面操作具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
## 技术栈 ## 技术栈
@@ -47,17 +47,23 @@
- 批量更新Excel中的生肖信息 - 批量更新Excel中的生肖信息
- 支持日期格式自动识别 - 支持日期格式自动识别
### 6. 导出数据模板 (📦 导出数据模板) ### 6. 园长一键签名 (💴 园长一键签名)
- 一键为所有生成的报告添加园长签名
- 自动识别输出文件夹中的PPT文件
- 批量替换签名占位符为实际签名图片
### 7. 导出数据模板 (📦 导出数据模板)
- 生成标准化的数据模板ZIP包 - 生成标准化的数据模板ZIP包
- 包含示例Excel文件和图片文件夹结构 - 包含示例Excel文件和图片文件夹结构
- 方便新用户快速上手 - 方便新用户快速上手
### 7. 初始化系统 (📤 初始化系统) ### 8. 初始化系统 (📤 初始化系统)
- 自动创建必要的目录结构 - 自动创建必要的目录结构
- 安装所需字体文件 - 安装所需字体文件
- 配置系统环境 - 配置系统环境
### 8. 字体安装 (🔤 字体安装) ### 9. 字体安装 (🔤 字体安装)
- 自动检测系统是否安装了指定字体 - 自动检测系统是否安装了指定字体
- 自动安装项目所需的字体文件 - 自动安装项目所需的字体文件
- 支持方正兰亭黑简体和方正少儿简体字体 - 支持方正兰亭黑简体和方正少儿简体字体
@@ -70,7 +76,7 @@ growth_report/
├── UI.py # 图形用户界面入口tkinter ├── UI.py # 图形用户界面入口tkinter
├── main_nicegui.py # NiceGUI界面入口 ├── main_nicegui.py # NiceGUI界面入口
├── main.pyw # Windows图形界面启动文件 ├── main.pyw # Windows图形界面启动文件
├── config.env.toml # 项目配置文件 ├── config.toml # 项目配置文件
├── pyproject.toml # 项目依赖配置 ├── pyproject.toml # 项目依赖配置
├── start_app.bat # Windows启动批处理文件 ├── start_app.bat # Windows启动批处理文件
├── README.md # 项目说明文档 ├── README.md # 项目说明文档
@@ -199,7 +205,7 @@ pip install -r requirements.txt
### 初始化设置 ### 初始化设置
1. 编辑`config.env.toml`配置文件设置API密钥和其他参数 1. 编辑`config.toml`配置文件设置API密钥和其他参数
2. 准备Excel数据文件按指定格式 2. 准备Excel数据文件按指定格式
3. 准备图片资源文件夹(按指定结构) 3. 准备图片资源文件夹(按指定结构)
4. 准备PPT模板文件 4. 准备PPT模板文件

108
README.md
View File

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

View File

@@ -1,98 +0,0 @@
[paths]
# TODO PPT模版路径
source_file = "大班幼儿学期发展报告.pptx"
# 输出文件夹
output_folder = "output"
# Excel数据文件路径
excel_file = "names.xlsx"
# 图片资源文件夹
image_folder = "images"
# 字体文件夹
fonts_dir = "fonts"
[class_info]
# TODO 班级名称
class_name = "K4D"
# TODO 老师名单 (数组格式)
teachers = [""]
[defaults]
# 当Excel中没有评语时的默认内容
default_comment = "暂无评语"
age_group = "大班上学期"
# ========================
# Excel 配置 (Data)
# ========================
[excel]
sheet_name = "Sheet1"
# 对应Excel表头名称顺序必须与代码中的解包顺序一致
# 顺序:姓名, 英文名, 性别, 生日, 属相, 好朋友, 爱好, 游戏, 食物, 评语
columns = [
"姓名",
"英文名",
"性别",
"生日",
"属相",
"我的好朋友",
"我的爱好",
"喜欢的游戏",
"喜欢吃的食物",
"表现特征",
"评价",
]
# TODO API 配置
[ai]
api_key = ""
api_url = ""
model = ""
prompt = """
# Role
你是一位拥有20年经验的资深幼儿园主班老师。你的文笔温暖、细腻、充满爱意擅长发现每个孩子身上独特的闪光点。你的评语风格是“治愈系”的能让家长读完后感到欣慰并对未来充满希望。
# Goal
请根据用户提供的【幼儿姓名】、【年龄段/班级】以及【日常表现关键词/评分数据】,撰写一份高质量的学期末成长评语。
# Constraints & Rules
1. **称呼处理**
- 自动识别用户输入的姓名。
- **必须去掉姓氏**,只使用名。
- 统一格式为:“[名]宝贝,你好!”或“[名]宝贝:”。
- 例如:“王小明” -> “小明宝贝”;“李在这个” -> “在这个宝贝”。
2. **分龄侧重 (根据 Age_Group 调整侧重点)**
- **小班 (3-4岁)**:侧重于适应集体生活、情绪稳定性、基本生活自理能力(吃饭、午睡、如厕)、愿意与老师互动。
- **中班 (4-5岁)**:侧重于社交互动、分享与合作、动手能力、好奇心、规则意识的建立、自信心的增强。
- **大班 (5-6岁)**:侧重于学习习惯、逻辑思维、领导力/榜样作用、任务意识、为幼小衔接做的准备、抗挫折能力。
3. **写作结构 (三段式)**
- **开头**:亲切的问候 + 总体印象(用美好的形容词,如文静、活泼、机灵等)。
- **正文**:结合提供的【表现关键词】,具体描述孩子的进步和优点(必须具体,拒绝空洞)。
- **结尾**:委婉地提出一点小小的期望(用“如果你能...老师会更为你骄傲”的句式),并送上新学期的祝福。
4. **语气风格**
- 积极正面,多用肯定句。
- 避免生硬的批评,将缺点转化为“待提升的潜力”或“期望”。
- 字数控制在 150-250 字之间适合PPT展示
# Workflow
1. 分析用户输入的年龄段,确定评价基调。
2. 处理姓名,提取昵称。
3. 将输入的关键词串联成通顺、优美的句子。
4. 按照三段式结构输出最终评语。
# Input Format
用户将提供 JSON 格式或特定格式的数据,包含:
- Name {{name}}
- Age_Group {{class_name}}
- Traits (表现关键词/特征,如:吃饭香、爱画画、有些胆小、数学好)
# Output Example
(假设输入Name=张图图, Age_Group=小班, Traits=适应能力强, 爱笑, 挑食)
图图宝贝,你好!
你是一个爱笑的小天使,每天早上都能看到你甜甜的笑脸,老师的心都要被你融化了。这个学期你进步真大呀,从一开始的哭鼻子到现在能开心地参与游戏,你的适应能力让老师感到惊喜。在集体活动中,你总是那么投入。
不过,老师发现你在吃饭时偶尔会把不喜欢的青菜挑出来哦。如果你能和青菜宝宝做好朋友,把身体练得棒棒的,那就更完美啦!
祝可爱的图图宝贝新年快乐,健康成长!
"""

View File

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

View File

@@ -1,6 +1,9 @@
import os import os
import sys import sys
from loguru import logger
from nicegui import app
# 1. 处理读取库 # 1. 处理读取库
try: try:
import tomllib as toml_read # Python 3.11+ import tomllib as toml_read # Python 3.11+
@@ -18,104 +21,95 @@ except ImportError:
# 如果没安装,提供一个 fallback 提示 # 如果没安装,提供一个 fallback 提示
toml_write = None toml_write = None
def get_base_dir(): def get_base_dir():
if getattr(sys, 'frozen', False): if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable) return os.path.dirname(sys.executable)
else: else:
# 假设当前文件在项目根目录或根目录下的某个文件夹中 # 假设当前文件在项目根目录或根目录下的某个文件夹中
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_resource_path(relative_path): def get_resource_path(relative_path):
base_path = get_base_dir() base_path = get_base_dir()
external_path = os.path.join(base_path, relative_path) external_path = os.path.join(base_path, relative_path)
if os.path.exists(external_path): if os.path.exists(external_path):
return external_path return external_path
if getattr(sys, 'frozen', False): if getattr(sys, "frozen", False):
internal_path = os.path.join(sys._MEIPASS, relative_path) internal_path = os.path.join(sys._MEIPASS, relative_path)
if os.path.exists(internal_path): if os.path.exists(internal_path):
return internal_path return internal_path
return external_path return external_path
def init_storage():
"""如果 storage 是空的,则注入默认配置"""
cfg = app.storage.general
if "class_name" not in cfg:
logger.info("初始化默认配置...")
default_config = {
"data_folder": os.path.join(get_base_dir(), "data"),
"templates_folder": os.path.join(get_base_dir(), "templates"),
"source_file": "大四班 幼儿学期发展报告.pptx",
"output_folder": "output",
"excel_file": "names.xlsx",
"image_folder": "images",
"fonts_dir": "fonts",
"signature_image": "signature.png",
"class_name": "K4D",
"teachers": ["康璐璐", "冯宇阳", "孙继艳"],
"class_type": 2,
"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",
},
}
cfg.update(default_config)
# ========================================== # ==========================================
# 1. 配置加载 (Config Loader) # 1. 配置加载 (Config Loader)
# ========================================== # ==========================================
def load_config(config_filename="config.toml"): def load_config():
config_path = get_resource_path(config_filename)
if not os.path.exists(config_path):
# 如果彻底找不到,返回一个最小化的默认值,防止程序奔溃
return { "source_file": "", "ai": {"api_key": ""}, "teachers": [] }
try: try:
with open(config_path, "rb") as f: config_data = app.storage.general
data = toml_read.load(f)
base_dir = get_base_dir() base_dir = get_base_dir()
# 使用 .get() 安全获取,防止 KeyError: 'paths'
paths = data.get("paths", {})
class_info = data.get("class_info", {})
defaults = data.get("defaults", {})
config = { config = {
"root_path": base_dir, "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", ""))), "source_file": get_resource_path(
"excel_file": get_resource_path(os.path.join("data", paths.get("excel_file", ""))), os.path.join("templates", config_data.get("source_file", ""))
"image_folder": get_resource_path(os.path.join("data", paths.get("image_folder", ""))), ),
"fonts_dir": get_resource_path(paths.get("fonts_dir", "fonts")), "excel_file": get_resource_path(
"output_folder": os.path.join(base_dir, paths.get("output_folder", "output")), os.path.join("data", config_data.get("excel_file", "names"))
"signature_image": get_resource_path(os.path.join("data", paths.get("signature_image", ""))), ),
"image_folder": get_resource_path(
"class_name": class_info.get("class_name", "未命名班级"), os.path.join("data", config_data.get("image_folder", ""))
"teachers": class_info.get("teachers", []), ),
"default_comment": defaults.get("default_comment", "暂无评语"), "fonts_dir": get_resource_path(config_data.get("fonts_dir", "fonts")),
"age_group": defaults.get("age_group", "大班上学期"), "output_folder": os.path.join(
"ai": data.get("ai", {"api_key": "", "api_url": "", "model": ""}), base_dir, config_data.get("output_folder", "output")
),
"signature_image": get_resource_path(
os.path.join("data", config_data.get("signature_image", "signature.png"))
),
"class_name": config_data.get("class_name", "未命名班级"),
"teachers": config_data.get("teachers", []),
"class_type": config_data.get("class_type", 0),
"default_comment": config_data.get("default_comment", "暂无评语"),
"age_group": config_data.get("age_group", "大班上学期"),
"ai": config_data.get(
"ai", {"api_key": "", "api_url": "", "model": "", "prompt": ""}
),
} }
return config return config
except Exception as e: except Exception as e:
print(f"解析配置文件失败: {e}") print(f"解析配置文件失败: {e}")
return {} 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", ""))),
},
"class_info": {
"class_name": config_data.get("class_name", ""),
"teachers": config_data.get("teachers", []),
},
"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.

View File

@@ -1,32 +1,33 @@
import os import os
import sys import sys
from nicegui import ui, app, run, native
from loguru import logger
from screeninfo import get_monitors
import traceback import traceback
from config.config import load_config
from loguru import logger
from nicegui import ui, app, run, native
from screeninfo import get_monitors
from config.config import init_storage
# 导入我们的模块 # 导入我们的模块
from ui.core.logger import setup_logger from ui.core.logger import setup_logger
from utils.font_utils import install_fonts_from_directory
from ui.views.home_page import create_page
from ui.views.config_page import create_config_page from ui.views.config_page import create_config_page
from ui.views.convert_pdf_page import create_convert_pdf_page
from ui.views.data_page import create_data_page
from ui.views.home_page import create_home_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.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding="utf-8")
# 1. 初始化配置
config = load_config("config.toml")
setup_logger() setup_logger()
# === 关键修改:定义一个获取路径的通用函数 === # === 关键修改:定义一个获取路径的通用函数 ===
def get_path(relative_path): def get_path(relative_path):
""" """
获取资源的绝对路径 获取资源的绝对路径
兼容开发环境(直接运行) 生产环境(打包成exe后解压的临时目录) 兼容开发环境(直接运行) 生产环境(打包成exe后解压的临时目录)
""" """
if hasattr(sys, '_MEIPASS'): if hasattr(sys, "_MEIPASS"):
base_path = sys._MEIPASS base_path = sys._MEIPASS
else: else:
# 开发环境当前目录 # 开发环境当前目录
@@ -63,32 +64,55 @@ def calculate_window_size():
logger.info(f"屏幕分辨率: {screen_width}x{screen_height}") logger.info(f"屏幕分辨率: {screen_width}x{screen_height}")
logger.info(f"设置窗口大小为: {target_width}x{target_height}") logger.info(f"设置窗口大小为: {target_width}x{target_height}")
return (target_width, target_height) return target_width, target_height
except Exception as e: except Exception as e:
logger.warning(f"无法获取屏幕分辨率 ({e}),使用默认大小 (900, 900)") logger.warning(f"无法获取屏幕分辨率 ({e}),使用默认大小 (900, 900)")
return (900, 900) # 失败时的默认值 return 900, 900 # 失败时的默认值
# 1. 挂载静态资源 (CSS/图片) # 1. 挂载静态资源 (CSS/图片)
# 注意:这里使用 get_path 确保打包后能找到 # 注意:这里使用 get_path 确保打包后能找到
static_dir = get_path(os.path.join("ui", "assets")) static_dir = get_path(os.path.join("ui", "assets"))
app.add_static_files('/assets', static_dir) app.add_static_files("/assets", static_dir)
# 3. 页面路由 # 3. 页面路由
@ui.page('/') @ui.page("/")
def index_page(): def index_page():
create_page() create_home_page()
@ui.page('/config')
@ui.page("/config")
def config_page(): def config_page():
create_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)
@ui.page("/convert_pdf")
def convert_pdf_page(folder: str = ""):
create_convert_pdf_page(folder)
# 4. 启动时钩子 # 4. 启动时钩子
async def startup_check(): async def startup_check():
try: try:
logger.info("系统启动: 初始化资源...") logger.info("系统启动: 初始化资源...")
await run.io_bound(install_fonts_from_directory, config["fonts_dir"]) # 初始化存储
os.makedirs(config["output_folder"], exist_ok=True) init_storage()
cfg = app.storage.general
await run.io_bound(install_fonts_from_directory, cfg["fonts_dir"])
os.makedirs(cfg["output_folder"], exist_ok=True)
logger.success("资源初始化完成") logger.success("资源初始化完成")
except Exception as e: except Exception as e:
logger.error(f"初始化失败: {e}") logger.error(f"初始化失败: {e}")
@@ -104,5 +128,4 @@ if __name__ in {"__main__", "__mp_main__"}:
native=True, native=True,
window_size=calculated_size, window_size=calculated_size,
port=native.find_open_port(), # 自动寻找端口 port=native.find_open_port(), # 自动寻找端口
reload=False
) )

View File

@@ -1,81 +0,0 @@
import sys
import tkinter as tk
from loguru import logger
from ui.app_window import ReportApp
from utils.log_handler import setup_logging
# 全局变量,用于判断日志是否已初始化
LOGGING_INITIALIZED = False
# --- 全局错误处理 ---
def handle_exception(exc_type, exc_value, exc_traceback):
"""
捕获未被 try/except 块处理的全局异常(如线程崩溃)。
"""
if exc_type is KeyboardInterrupt:
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
# 尝试使用 loguru 记录
if LOGGING_INITIALIZED:
logger.error("捕获到未处理的全局异常:", exc_info=(exc_type, exc_value, exc_traceback))
else:
# 如果日志系统未初始化,直接打印到标准错误流,确保用户看到
print("FATAL ERROR (Log Not Initialized):", file=sys.stderr)
import traceback
traceback.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr)
sys.excepthook = handle_exception
# --------------------
def create_main_window():
global LOGGING_INITIALIZED
# 顶级 try 块,捕获日志初始化阶段的错误
try:
# 1. 初始化日志
setup_logging()
LOGGING_INITIALIZED = True
logger.info("正在启动应用程序...")
# 2. 启动 UI
root = tk.Tk()
# 这一行可以设置图标 (如果有 icon.ico 文件)
# root.iconbitmap(os.path.join(os.path.dirname(__file__), "public", "icon.ico"))
# 确保 ReportApp 实例化时不会出现路径错误
app = ReportApp(root)
# 3. 进入主循环
root.mainloop()
except Exception as e:
# 如果日志系统已启动,使用 logger 记录
if LOGGING_INITIALIZED:
logger.error(f"应用程序启动/主循环出错: {e}", exc_info=True)
else:
# 如果日志系统未初始化,直接打印到控制台
print(f"FATAL STARTUP ERROR: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# 确保窗口被销毁
if 'root' in locals() and root:
root.destroy()
# 非窗口模式下,在启动错误时等待用户查看
if not getattr(sys, 'frozen', False) or not any(arg in sys.argv for arg in ('--windowed', '-w')):
input("按任意键退出...")
sys.exit(1)
if __name__ == "__main__":
create_main_window()

View File

@@ -8,6 +8,7 @@ dependencies = [
"comtypes>=1.4.13", "comtypes>=1.4.13",
"langchain>=1.1.3", "langchain>=1.1.3",
"langchain-openai>=1.1.1", "langchain-openai>=1.1.1",
"logger",
"loguru>=0.7.3", "loguru>=0.7.3",
"nicegui>=3.4.0", "nicegui>=3.4.0",
"openpyxl>=3.1.5", "openpyxl>=3.1.5",

View File

@@ -37,10 +37,10 @@ echo [INFO] 正在拉起主程序...
echo --------------------------------------------------- echo ---------------------------------------------------
:: ======================================================= :: =======================================================
:: 【关键修改】路径改为根目录的 main.pyw :: 【关键修改】路径改为根目录的 main.py
:: 使用 start 命令启动,这样黑色的 CMD 窗口可以随后立即关闭 :: 使用 start 命令启动,这样黑色的 CMD 窗口可以随后立即关闭
:: ======================================================= :: =======================================================
start "" uv run main.pyw start "" uv run main.py
:: 等待 1 秒确保启动 :: 等待 1 秒确保启动
timeout /t 1 >nul timeout /t 1 >nul

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,236 +0,0 @@
import os
import time
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox, filedialog
import threading
import queue
import sys
from loguru import logger
from config.config import load_config
from utils.font_utils import install_fonts_from_directory
from utils.log_handler import log_queue
# 导入业务逻辑
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
config = load_config("config.toml")
class ReportApp:
def __init__(self, root):
self.root = root
self.root.title("🌱 尚城幼儿园成长报告助手")
self.root.geometry("720x760")
# 线程控制
self.stop_event = threading.Event()
self.is_running = False
# 尝试初始化 UI
try:
self._setup_ui()
except Exception as e:
logger.critical(f"UI 初始化失败: {e}", exc_info=True)
messagebox.showerror("致命错误", f"界面初始化失败,请检查日志。\n错误: {e}")
self.root.destroy()
sys.exit(1)
# 尝试初始化项目资源
try:
self.init_project()
except Exception as e:
logger.critical(f"项目资源初始化失败: {e}", exc_info=True)
messagebox.showerror("致命错误", f"项目资源初始化失败,请检查日志。\n错误: {e}")
self.root.destroy()
sys.exit(1)
self._start_log_polling()
def _setup_ui(self):
# 样式配置
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("Stop.TButton", foreground="red", font=("微软雅黑", 10, "bold"))
# 1. 标题
header = ttk.Frame(self.root, padding="10 15 10 5")
header.pack(fill=tk.X)
ttk.Label(header, text="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack()
ttk.Label(header, text="By 寒寒 | 这里的每一份评语都充满爱意", font=("微软雅黑", 9), foreground="gray").pack()
# 2. 功能区容器
main_content = ttk.Frame(self.root, padding="10 15 10 5")
main_content.pack(fill=tk.X)
# === 进度条区域 ===
progress_frame = ttk.Frame(self.root, padding="10 15 10 5")
progress_frame.pack(fill=tk.X, pady=(0, 10))
# 进度条 Label
self.progress_label = ttk.Label(progress_frame, text="⛳ 任务进度: 待命", font=("微软雅黑", 10))
self.progress_label.pack(fill=tk.X, pady=(0, 2))
# 进度条
self.progressbar = ttk.Progressbar(progress_frame, orient="horizontal", mode="determinate")
self.progressbar.pack(fill=tk.X, expand=True)
# === A组: 核心功能 ===
self._create_btn_group(main_content, "🛠️ 核心功能", [
("📁 生成图片路径", lambda: self.run_task(generate_template)),
("🤖 生成评语 (AI)", lambda: self.run_task(generate_comment_all)),
("📊 生成报告 (PPT)", lambda: self.run_task(generate_report)),
("📑 格式转换 (PDF)", lambda: self.run_task(batch_convert_folder, config.get("output_folder"))),
("🐂 生肖转化 (生日)", lambda: self.run_task(generate_zodiac)),
], columns=3)
# === B组: 数据管理 ===
self._create_btn_group(main_content, "📦 数据管理", [
("📦 导出模板 (Zip)", self.run_export_template),
("📤 导出备份 (Zip)", self.run_export_data),
], columns=2)
# === C组: 系统操作 (含停止按钮) ===
self._create_btn_group(main_content, "⚙️ 系统操作", [
("⛔ 停止当前任务", self.stop_current_task),
("⚠️ 初始化系统", self.run_init),
("🚪 退出系统", self.quit_app),
], columns=3, special_styles={"⛔ 停止当前任务": "Stop.TButton"})
# 3. 日志区
log_frame = ttk.LabelFrame(self.root, text="📝 系统实时日志", padding="10 15 10 5")
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)
def _create_btn_group(self, parent, title, buttons, columns=2, special_styles=None):
frame = ttk.LabelFrame(parent, text=title, padding=10)
frame.pack(fill=tk.X, pady=5)
special_styles = special_styles or {}
for i, (text, func) in enumerate(buttons):
style = special_styles.get(text, "TButton")
btn = ttk.Button(frame, text=text, command=func, style=style)
r, c = divmod(i, columns)
btn.grid(row=r, column=c, padx=5, pady=5, sticky="ew")
for i in range(columns):
frame.columnconfigure(i, weight=1)
def _start_log_polling(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._start_log_polling)
def init_project(self):
# 1. 资源准备
if install_fonts_from_directory(config["fonts_dir"]):
logger.info("等待系统识别新安装的字体...")
time.sleep(2)
# 2. 创建输出文件夹
os.makedirs(config["output_folder"], exist_ok=True)
logger.success("项目初始化完成.....")
# --- 任务运行核心逻辑 ---
def run_task(self, target_func, *args, **kwargs):
if self.is_running:
messagebox.showwarning("忙碌中", "请先等待当前任务完成或点击【停止当前任务】")
return
self.stop_event.clear()
self.is_running = True
# 将进度更新方法作为参数传入
kwargs['progress_callback'] = self.update_progress
def thread_worker():
try:
# 尝试传入 stop_event
try:
target_func(*args, stop_event=self.stop_event, **kwargs)
except TypeError:
# 如果旧函数不支持 stop_event则普通运行
target_func(*args, **kwargs)
except Exception as e:
logger.error(f"任务出错: {e}")
import traceback
logger.error(traceback.format_exc())
finally:
self.is_running = False
logger.info("系统准备就绪.....")
self.reset_progress() # 重置进度条
threading.Thread(target=thread_worker, daemon=True).start()
def stop_current_task(self):
if not self.is_running:
return
if messagebox.askyesno("确认", "确定要中断当前任务吗?"):
self.stop_event.set()
logger.warning("正在发送停止信号...")
# --- 具体按钮事件 ---
def run_export_template(self):
path = filedialog.askdirectory()
if path: self.run_task(export_templates_folder, path)
def run_export_data(self):
path = filedialog.askdirectory()
if path: self.run_task(export_data, path)
def run_init(self):
if messagebox.askokcancel("警告", "确定重置系统吗?数据将丢失!"):
self.run_task(initialize_project)
def quit_app(self):
if self.is_running:
messagebox.showwarning("提示", "请先停止任务")
return
self.root.destroy()
sys.exit()
# --- 进度条更新(实现线程安全更新) ---
def update_progress(self, current, total, task_name="任务"):
"""
线程安全地更新进度条和标签
:param current: 当前完成的项目数
:param total: 总项目数
:param task_name: 当前任务名称
"""
if total <= 0:
# 重置进度条
self.progressbar['value'] = 0
self.progress_label.config(text=f"任务进度: {task_name} 完成或待命")
return
percentage = int((current / total) * 100)
display_text = f"{task_name}: {current}/{total} ({percentage}%)"
# 使用 after 确保在主线程中更新 UI
self.root.after(0, self._set_progress_ui, percentage, display_text)
def _set_progress_ui(self, percentage, display_text):
"""实际更新 UI 的私有方法"""
self.progressbar['value'] = percentage
self.progress_label.config(text=display_text)
def reset_progress(self):
"""任务结束后重置进度条"""
self.root.after(0, self._set_progress_ui, 0, "任务进度: 就绪")

View File

@@ -2,13 +2,13 @@
/* 全局字体 */ /* 全局字体 */
body { body {
font-family: "微软雅黑", "Microsoft YaHei", sans-serif; font-family: '微软雅黑', 'Microsoft YaHei', sans-serif;
background-color: #f0f4f8; background-color: #f0f4f8;
} }
/* 标题栏 */ /* 标题栏 */
.app-header { .app-header {
background-color: #2E8B57; /* SeaGreen */ background-color: #2e8b57; /* SeaGreen */
color: white; color: white;
} }
@@ -48,12 +48,17 @@ body {
} }
/* 绿色标题 */ /* 绿色标题 */
.text-green { color: #166534; } .text-green {
color: #166534;
}
/* 蓝色标题 */ /* 蓝色标题 */
.text-blue { color: #1e40af; } .text-blue {
color: #1e40af;
}
/* 红色标题 */ /* 红色标题 */
.text-red { color: #991b1b; } .text-red {
color: #991b1b;
}
/* assets/style.css */ /* assets/style.css */
@@ -118,7 +123,8 @@ body {
height: 0px !important; height: 0px !important;
background: transparent !important; background: transparent !important;
} }
#nicegui-content, #q-app { #nicegui-content,
#q-app {
/* 确保容器内容可以滚动,但滚动条被隐藏 */ /* 确保容器内容可以滚动,但滚动条被隐藏 */
overflow: auto; overflow: auto;
@@ -128,3 +134,49 @@ body {
/* IE/Edge 隐藏滚动条 */ /* IE/Edge 隐藏滚动条 */
-ms-overflow-style: none; -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;
}

View File

@@ -1,76 +1,82 @@
from nicegui import ui
import os import os
from nicegui import ui, app
from ui.views.templates.back_home import backHome
from utils.template_utils import get_template_files from utils.template_utils import get_template_files
# 修改点 1统一导入避免与变量名 config 冲突
from config.config import load_config, save_config
def create_config_page(): def create_config_page():
# 修改点 2将加载逻辑放入页面生成函数内确保每次刷新页面获取最新值 # 获取当前持久化存储中的数据
conf_data = load_config("config.toml") cfg = app.storage.general
template_options = get_template_files()
current_filename = os.path.basename(conf_data.get('source_file', ''))
template_options = get_template_files()
current_filename = os.path.basename(cfg.get("source_file", ""))
if current_filename and current_filename not in template_options: if current_filename and current_filename not in template_options:
template_options.append(current_filename) template_options.append(current_filename)
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />') ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
ui.add_head_html("""
# 样式修正:添加全屏且不滚动条的 CSS
ui.add_head_html('''
<style> <style>
body { overflow: hidden; } body { overflow: hidden; }
.main-card { height: calc(100vh - 100px); display: flex; flex-direction: column; } .main-card { height: calc(100vh - 100px); display: flex; flex-direction: column; }
.q-tab-panels { flex-grow: 1; overflow-y: auto !important; } .q-tab-panels { flex-grow: 1; overflow-y: auto !important; }
</style> </style>
''') """)
with ui.header().classes('app-header items-center justify-between shadow-md'): with ui.header().classes("app-header items-center justify-between shadow-md"):
# 左侧:图标和标题 backHome()
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.card().classes('w-full max-w-5xl mx-auto shadow-lg main-card p-0'): with ui.tabs().classes("w-full") as tabs:
with ui.tabs().classes('w-full') as tabs: tab_path = ui.tab("路径设置", icon="folder")
tab_path = ui.tab('路径设置', icon='folder') tab_class = ui.tab("班级与教师", icon="school")
tab_class = ui.tab('班级与教师', icon='school') tab_ai = ui.tab("AI 接口配置", icon="psychology")
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_panels(tabs, value=tab_path).classes("w-full flex-grow bg-transparent"):
# --- 路径设置 --- # --- 1. 路径设置 ---
with ui.tab_panel(tab_path).classes('w-full p-0'): with ui.tab_panel(tab_path).classes("w-full p-4 gap-4"):
with ui.column().classes('w-full p-4 gap-4'): # 注意:这里改用普通的 value= 参数,不使用 bind_value
source_file = ui.select(options=template_options, label='PPT 模板', value=current_filename).props('outlined fill-input').classes('w-full') source_file = ui.select(options=template_options, label="PPT 模板", value=cfg.get('source_file')).props(
excel_file = ui.input('Excel 文件', value=os.path.basename(conf_data.get('excel_file', ''))).props('outlined').classes('w-full') "outlined").classes("w-full")
image_folder = ui.input('图片目录', value=os.path.basename(conf_data.get('image_folder', ''))).props('outlined').classes('w-full') excel_file = ui.input("Excel 文件名", value=cfg.get('excel_file')).props("outlined").classes("w-full")
output_folder = ui.input('输出目录', value=os.path.basename(conf_data.get('output_folder', 'output'))).props('outlined').classes('w-full') image_folder = ui.input("图片目录名", value=cfg.get('image_folder')).props("outlined").classes("w-full")
output_folder = ui.input("输出目录名", value=cfg.get('output_folder', 'output')).props(
"outlined").classes("w-full")
# --- 班级信息 --- # --- 2. 班级信息 ---
with ui.tab_panel(tab_class).classes('w-full p-0'): with ui.tab_panel(tab_class).classes("w-full p-4 gap-4"):
with ui.column().classes('w-full p-4 gap-4'): class_name = ui.input("班级名称", value=cfg.get('class_name')).props("outlined").classes("w-full")
class_name = ui.input('班级名称', value=conf_data.get('class_name', '')).props('outlined').classes('w-full')
age_group = ui.select( age_group = ui.select(
options=['小班上学期', '小班下学期', '中班上学期', '中班下学期', '大班上学期', '大班下学期'], options=["小班上学期", "小班下学期", "中班上学期", "中班下学期", "大班上学期", "大班下学期"],
label='年龄段', value=conf_data.get('age_group', '中班上学期') label="年龄段",
).props('outlined').classes('w-full') value=cfg.get('age_group')
teachers_text = ui.textarea('教师名单', value='\n'.join(conf_data.get('teachers', []))).props('outlined').classes('w-full h-40') ).props("outlined").classes("w-full")
# --- AI 配置 --- teachers_text = ui.textarea("教师名单(每行一个)", value="\n".join(cfg.get("teachers", []))).props(
with ui.tab_panel(tab_ai).classes('w-full p-0'): "outlined").classes("w-full h-40")
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') class_type = ui.select(
ai_url = ui.input('API URL', value=conf_data['ai'].get('api_url', '')).props('outlined').classes('w-full') options={0: "便宜班", 1: "昂贵班", 2: "昂贵的双木桥班"},
ai_model = ui.input('Model Name', value=conf_data['ai'].get('model', '')).props('outlined').classes('w-full') label="班级类型",
ai_prompt = ui.textarea('System Prompt', value=conf_data['ai'].get('prompt', '')).props('outlined').classes('w-full h-full') value=cfg.get('class_type', 0)
# 底部固定按钮 ).props("outlined").classes("w-full")
with ui.row().classes('w-full p-4'):
async def handle_save(): # --- 3. AI 配置 ---
new_data = { with ui.tab_panel(tab_ai).classes("w-full p-4 gap-4"):
ai_data = cfg.get('ai', {}) # 获取子字典
ai_key = ui.input("API Key", value=ai_data.get("api_key")).props("outlined password").classes("w-full")
ai_url = ui.input("API URL", value=ai_data.get("api_url")).props("outlined").classes("w-full")
ai_model = ui.input("Model Name", value=ai_data.get("model")).props("outlined").classes("w-full")
ai_prompt = ui.textarea("System Prompt", value=ai_data.get("prompt")).props("outlined").classes(
"w-full h-64")
# --- 底部按钮:点击后统一更新到 Storage ---
with ui.row().classes("w-full p-4"):
def handle_manual_save():
# 统一更新到 app.storage.general
# NiceGUI 会在此时感知到字典变化并触发自动保存到 JSON 文件
cfg.update({
"source_file": source_file.value, "source_file": source_file.value,
"excel_file": excel_file.value, "excel_file": excel_file.value,
"image_folder": image_folder.value, "image_folder": image_folder.value,
@@ -78,15 +84,15 @@ def create_config_page():
"class_name": class_name.value, "class_name": class_name.value,
"age_group": age_group.value, "age_group": age_group.value,
"teachers": [t.strip() for t in teachers_text.value.split('\n') if t.strip()], "teachers": [t.strip() for t in teachers_text.value.split('\n') if t.strip()],
"class_type": class_type.value,
"ai": { "ai": {
"api_key": ai_key.value, "api_key": ai_key.value,
"api_url": ai_url.value, "api_url": ai_url.value,
"model": ai_model.value, "model": ai_model.value,
"prompt": ai_prompt.value "prompt": ai_prompt.value,
} }
} })
# 修改点 4直接调用导入的 save_config 函数名 ui.notify("配置已成功更新至系统存储", type="positive", icon="save")
success, message = save_config(new_data)
ui.notify(message, type='positive' if success else 'negative')
ui.button('保存配置', on_click=handle_save).classes('w-full py-4').props('outline color=primary') ui.button("保存配置", icon="save", on_click=handle_manual_save).classes("w-full py-4 shadow-md").props(
"color=primary")

View File

@@ -0,0 +1,256 @@
import os
import comtypes.client
import pythoncom
from loguru import logger
from nicegui import ui
from ui.views.templates.back_home import backHome
from utils.file_utils import open_folder
progress_bar = None
progress_label = None
powerpoint = None
def onload_page(folder: str = ""):
global powerpoint
pdf_path = os.path.join(folder, "PDF")
if not os.path.exists(pdf_path):
os.makedirs(pdf_path)
if not powerpoint:
try:
pythoncom.CoInitialize()
powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
logger.success("PowerPoint 应用启动成功")
except Exception as e:
logger.error(f"PowerPoint 应用启动失败: {str(e)}")
def update_progress(current: int, total: int, message: str):
global progress_bar, progress_label
if total <= 0:
pct = 0
text = f"{message}: 准备中..."
else:
pct = current / total
text = f"{message}: {current}/{total} ({int(pct * 100)}%)"
# 更新 UI
if progress_bar:
progress_bar.set_value(pct)
if progress_label:
progress_label.set_text(text)
def cleanup_powerpoint():
"""清理 PowerPoint COM 组件资源"""
global powerpoint
if powerpoint:
try:
powerpoint.Quit()
logger.success("PowerPoint 应用已关闭")
except Exception as e:
logger.error(f"关闭 PowerPoint 应用失败: {str(e)}")
finally:
powerpoint = None
def create_convert_pdf_page(folder: str = ""):
onload_page(folder)
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"):
backHome()
with ui.card().classes("w-full"):
with ui.row().classes("w-full justify-between"):
ui.label("📑 格式转换").classes("section-title")
with ui.row().classes("flex-1 justify-end"):
ui.button(
"📑 格式转换",
on_click=lambda: convert_all_pdf_files(folder),
).props().classes()
ui.button(
"📂 打开文件夹",
on_click=lambda: open_folder(folder),
).props("outline").classes()
ui.button(
"🔄 刷新数据",
on_click=lambda: convert_files_list(list_card, folder),
).props("outline").classes()
# 进度条
with ui.row().classes("w-full"):
global progress_label, progress_bar
progress_bar = ui.linear_progress(value=0, show_value=False).classes(
"h-4 rounded"
)
progress_bar.props("color=positive")
progress_label = ui.label("⛳ 任务进度: 待命").classes(
"font-bold text-gray-700 mb-1"
)
list_card = ui.card().classes("w-full p-4 gap-4")
convert_files_list(list_card, folder)
# 遍历目录
def convert_files_list(list_card, folder):
# 清空旧内容
list_card.clear()
files = get_convert_files(folder)
with list_card:
ui.label(f"📄 找到 {len(files)} 个 PPT 文件").classes(
"text-sm text-gray-600 mb-4"
)
# 创建可滚动的文件列表
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"
):
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: (convert_pdf_file(folder, f),),
).props("outline").classes("text-xs")
def get_convert_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 convert_file(folder, file_name: str):
"""
转换单个PPT文件为PDF格式
:param folder: PPT 文件所在目录
:param file_name: PPT 文件名称
"""
global progress_bar, progress_label, powerpoint
if not powerpoint:
logger.error("PowerPoint 应用未初始化,请重新加载页面")
ui.notify("PowerPoint 应用未初始化", type="negative")
return
try:
ppt_path = os.path.join(folder, file_name)
pdf_name = os.path.splitext(file_name)[0] + ".pdf"
pdf_path = os.path.join(folder, "PDF", pdf_name)
# 如果 PDF 已存在,可以选择跳过
if os.path.exists(pdf_path):
logger.info(f"[跳过] 已存在: {pdf_name}")
return True
# 打开 -> 另存为 -> 关闭
deck = powerpoint.Presentations.Open(ppt_path)
deck.SaveAs(pdf_path, 32)
deck.Close()
logger.success(f"文件转换完成: {file_name}")
return True
except Exception as e:
error_msg = f"转换失败: {str(e)}"
logger.error(error_msg)
return False
def convert_pdf_file(folder, file_name: str):
"""
转换单个PPT文件为PDF格式带UI更新
:param folder: PPT 文件所在目录
:param file_name: PPT 文件名称
"""
update_progress(0, 1, f"⛳ 任务进度: 开始转换 {file_name}")
result = convert_file(folder, file_name)
if result is True:
update_progress(1, 1, f"✅ 转换完成: {file_name}")
ui.notify(f"转换完成: {file_name}", type="positive")
elif result is None:
update_progress(1, 1, f"⏭️ 已存在: {file_name}")
ui.notify(f"已存在: {file_name}", type="info")
else:
update_progress(1, 1, f"❌ 转换失败: {file_name}")
ui.notify(f"转换失败: {file_name}", type="negative")
def convert_all_pdf_files(folder: str):
"""批量转换目录下所有PPT文件为PDF格式"""
global progress_bar, progress_label
pdf_files = get_convert_files(folder)
pdf_total = len(pdf_files)
if pdf_total == 0:
ui.notify("没有找到需要转换的PPT文件", type="warning")
return
success_count = 0
skip_count = 0
fail_count = 0
try:
for index, file in enumerate(pdf_files):
update_progress(index, pdf_total, f"正在转换: {file}")
result = convert_file(folder, file)
if result is True:
success_count += 1
elif result is None:
skip_count += 1
else:
fail_count += 1
update_progress(pdf_total, pdf_total, f"✅ 批量转换完成")
summary_msg = f"转换完成: 成功 {success_count} 个, 跳过 {skip_count} 个, 失败 {fail_count}"
logger.success(summary_msg)
ui.notify(summary_msg, type="positive")
except Exception as e:
error_msg = f"批量转换失败: {str(e)}"
update_progress(pdf_total, pdf_total, f"❌ 批量转换失败")
logger.error(error_msg)
ui.notify(error_msg, type="negative")

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

@@ -0,0 +1,133 @@
import os
import pandas as pd
from nicegui import ui
from config.config import load_config
from ui.views.templates.back_home import backHome
def create_data_page():
ui.add_head_html('<link href="/assets/style.css" rel="stylesheet" />')
with ui.header().classes("app-header items-center justify-between shadow-md"):
backHome()
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()
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")

View File

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

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

@@ -0,0 +1,215 @@
import os
import traceback
from loguru import logger
from nicegui import ui
from pptx import Presentation
from config.config import load_config
from utils.file_utils import open_folder
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()
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

@@ -0,0 +1,16 @@
from nicegui import ui
def backHome():
"""返回首页"""
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("回到首页")

View File

@@ -4,11 +4,14 @@ from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from loguru import logger from loguru import logger
import traceback
from config.config import load_config from config.config import load_config
config = load_config("config.toml") class_type_config =[
"本期开展了小袋鼠整合主题课程:(语言、社会、科学、健康、艺术)、生活数学;特色课程(英语、体能、美工、篮球)。",
"本学期开展了柏克莱主题课程(语言、社会、科学、艺术、健康);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。",
"本学期开展了双木桥主题课程(图说汉字、妙趣汉音、情智阅读、麦斯思维、专注力训练);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。"
]
def generate_comment(name, age_group, traits,sex): def generate_comment(name, age_group, traits,sex):
""" """
@@ -19,7 +22,14 @@ def generate_comment(name, age_group, traits,sex):
:param sex: 性别 :param sex: 性别
:return: 评语 :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"] ai_config = config["ai"]
llm = ChatOpenAI( llm = ChatOpenAI(
base_url=ai_config["api_url"], base_url=ai_config["api_url"],
@@ -42,7 +52,8 @@ def generate_comment(name, age_group, traits,sex):
"name": name, "name": name,
"age_group": age_group, "age_group": age_group,
"traits": traits, "traits": traits,
"sex": sex "sex": sex,
"class_type": class_type_config[(config.get("class_type", 0))],
}) })
cleaned_text = re.sub(r'\s+', '', comment) cleaned_text = re.sub(r'\s+', '', comment)
logger.success(f"学生:{name} =>生成评语成功: {cleaned_text}") logger.success(f"学生:{name} =>生成评语成功: {cleaned_text}")

View File

@@ -5,6 +5,7 @@ from loguru import logger
import zipfile import zipfile
import traceback import traceback
def export_templates_folder(output_folder, stop_event, progress_callback=None): def export_templates_folder(output_folder, stop_event, progress_callback=None):
""" """
将指定文件夹压缩为 zip 包 将指定文件夹压缩为 zip 包
@@ -121,7 +122,9 @@ def export_data(save_dir, root_dir=".", progress_callback=None):
has_files = True has_files = True
# 更新进度条 # 更新进度条
if progress_callback: if progress_callback:
progress_callback(processed_count + 1, total_files, "导出数据中...") progress_callback(
processed_count + 1, total_files, "导出数据中..."
)
if has_files: if has_files:
# 确保进度条最后能走到 100% # 确保进度条最后能走到 100%
@@ -139,6 +142,7 @@ def export_data(save_dir, root_dir=".", progress_callback=None):
except Exception as e: except Exception as e:
logger.error(f"导出过程出错: {str(e)}") logger.error(f"导出过程出错: {str(e)}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return None return None
@@ -218,6 +222,7 @@ def check_file_exists(file_path):
""" """
return file_path and isinstance(file_path, str) and os.path.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"): def get_output_pptx_files(output_dir="output"):
""" """
获取 output 文件夹下所有的 pptx 文件 获取 output 文件夹下所有的 pptx 文件
@@ -241,3 +246,17 @@ def get_output_pptx_files(output_dir="output"):
except Exception as e: except Exception as e:
logger.error(f"发生未知错误: {e}") logger.error(f"发生未知错误: {e}")
logger.error(traceback.format_exc()) 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,14 +4,13 @@
import os import os
import platform import platform
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
from config.config import load_config from config.config import load_config
config = load_config("config.toml") config = load_config()
def get_system_fonts(): def get_system_fonts():

View File

@@ -1,20 +1,17 @@
import os import os
import threading import threading
import time import time
import pythoncom
import pandas as pd
from loguru import logger
from pptx import Presentation
from rich.console import Console
import traceback import traceback
import comtypes.client import comtypes.client
import pandas as pd
import pythoncom
from loguru import logger
from pptx import Presentation
from config.config import load_config from config.config import load_config
from utils.agent_utils import generate_comment from utils.agent_utils import generate_comment
from utils.file_utils import check_file_exists, get_output_pptx_files 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 ( from utils.growt_utils import (
replace_one_page, replace_one_page,
replace_two_page, replace_two_page,
@@ -22,26 +19,26 @@ from utils.growt_utils import (
replace_four_page, replace_four_page,
replace_five_page, replace_five_page,
) )
from utils.pptx_utils import replace_picture from utils.image_utils import find_image_path
from utils.zodiac_utils import calculate_zodiac
# 如果你之前没有全局定义 console这里定义一个
console = Console()
# ==========================================
# 1. 配置区域 (Configuration)
# ==========================================
config = load_config("config.toml")
# ========================================== # ==========================================
# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹) # 1. 生成模板(根据names.xlsx文件生成名字图片文件夹)
# ========================================== # ==========================================
def generate_template(stop_event: threading.Event = None, progress_callback=None): def generate_template(stop_event: threading.Event = None, progress_callback=None):
"""" """ "
根据学生姓名生成相对应的以学生姓名的存放照片的文件夹 根据学生姓名生成相对应的以学生姓名的存放照片的文件夹
:params stop_event 任务是否停止事件监听UI的事件监听 :params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数 :params progress_callback 进度回调函数
""" """
# 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try: try:
# 1. 读取数据 # 1. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
@@ -89,6 +86,13 @@ def generate_comment_all(stop_event: threading.Event = None, progress_callback=N
:params stop_event 任务是否停止事件监听UI的事件监听 :params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数 :params progress_callback 进度回调函数
""" """
# 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try: try:
# 1. 读取数据 # 1. 读取数据
excel_path = config["excel_file"] excel_path = config["excel_file"]
@@ -109,9 +113,6 @@ def generate_comment_all(stop_event: threading.Event = None, progress_callback=N
if stop_event and stop_event.is_set(): if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....") logger.warning("任务正在停止中,正在中断中.....")
return # 停止任务 return # 停止任务
# 添加进度条
if progress_callback:
progress_callback(i + 1, total_count, "生成学生评语")
# 获取学生姓名 # 获取学生姓名
name = df.at[i, "姓名"] name = df.at[i, "姓名"]
@@ -135,7 +136,11 @@ def generate_comment_all(stop_event: threading.Event = None, progress_callback=N
if not pd.isna(current_comment) and str(current_comment).strip() != "": if not pd.isna(current_comment) and str(current_comment).strip() != "":
logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。") logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。")
continue continue
# 添加进度条
if progress_callback:
progress_callback(
i + 1, total_count, f"[{i + 1}/{total_count}] 正在生成评价: {name}"
)
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}") logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
try: try:
@@ -176,7 +181,13 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
根据学生姓名生成成长报告 根据学生姓名生成成长报告
:params stop_event 任务是否停止事件监听UI的事件监听 :params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数 :params progress_callback 进度回调函数
""" """ # 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
# 1. 检查模版文件是否存在 # 1. 检查模版文件是否存在
if not os.path.exists(config["source_file"]): if not os.path.exists(config["source_file"]):
logger.info(f"错误: 找不到模版文件 {config["source_file"]}") logger.info(f"错误: 找不到模版文件 {config["source_file"]}")
@@ -212,9 +223,6 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
if stop_event and stop_event.is_set(): if stop_event and stop_event.is_set():
logger.warning("任务正在停止中,正在中断中.....") logger.warning("任务正在停止中,正在中断中.....")
return return
# 更新进度条
if progress_callback:
progress_callback(i + 1, total_count, "生成报告")
# 解包数据 # 解包数据
( (
name, name,
@@ -228,7 +236,13 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
food, food,
comments, comments,
) = row_data ) = 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}") logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 每次循环重新加载模版 # 每次循环重新加载模版
@@ -262,11 +276,31 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
"birthday": ( "birthday": (
birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else " " 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 " ", "zodiac": (
"friend": str(friend).strip() if str(friend).strip() or not str(friend).strip().lower() else " ", str(zodiac).strip()
"hobby": str(hobby).strip() if str(hobby).strip() or not str(hobby).strip().lower() else " ", if str(zodiac).strip() or not str(zodiac).strip().lower()
"game": str(game).strip() if str(game).strip() or not str(game).strip().lower() else " ", else " "
"food": str(food).strip() if str(food).strip() or not str(food).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") me_image_path = find_image_path(student_image_folder, "me")
@@ -281,6 +315,7 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
class_image_path = find_image_path( class_image_path = find_image_path(
config["image_folder"], config["class_name"] config["image_folder"], config["class_name"]
) )
print(config["image_folder"], config["class_name"])
# 添加检查班级图片是否存在,若不存在则跳过 # 添加检查班级图片是否存在,若不存在则跳过
if check_file_exists(class_image_path): if check_file_exists(class_image_path):
@@ -334,17 +369,24 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
# ========================================== # ==========================================
# 4. 转换格式(根据names.xlsx文件生成PPT转PDF) # 4. 转换格式(根据names.xlsx文件生成PPT转PDF)
# ========================================== # ==========================================
def batch_convert_folder(folder_path, stop_event: threading.Event = None, progress_callback=None): def generate_convert_pdf(stop_event: threading.Event = None, progress_callback=None):
""" """
批量转换文件夹下的所有 PPT 批量转换文件夹下的所有 PPT
:params folder_path 需要转换的PPT文件夹 :params folder_path 需要转换的PPT文件夹
:params stop_event 任务是否停止事件监听UI的事件监听 :params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数 :params progress_callback 进度回调函数
""" """
# 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
# 子线程初始化 COM 组件 # 子线程初始化 COM 组件
pythoncom.CoInitialize() pythoncom.CoInitialize()
try: try:
folder_path = os.path.abspath(folder_path) folder_path = os.path.abspath(config.get("output_folder"))
if not os.path.exists(folder_path): if not os.path.exists(folder_path):
logger.error(f"文件夹不存在: {folder_path}") logger.error(f"文件夹不存在: {folder_path}")
return return
@@ -384,7 +426,9 @@ def batch_convert_folder(folder_path, stop_event: threading.Event = None, progre
logger.info(f"[跳过] 已存在: {filename}") logger.info(f"[跳过] 已存在: {filename}")
continue continue
logger.info(f"[{files.index(filename)}/{total_count}]正在转换: {filename} ...") logger.info(
f"[{files.index(filename)}/{total_count}]正在转换: {filename} ..."
)
try: try:
# 打开 -> 另存为 -> 关闭 # 打开 -> 另存为 -> 关闭
@@ -424,6 +468,13 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
:params stop_event 任务是否停止事件监听UI的事件监听 :params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数 :params progress_callback 进度回调函数
""" """
# 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try: try:
# 1. 读取数据 # 1. 读取数据
excel_path = config["excel_file"] excel_path = config["excel_file"]
@@ -444,7 +495,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.info(f"开始生成学生属相,共 {total_count} 位学生...") logger.info(f"开始生成学生属相,共 {total_count} 位学生...")
# 3. 预处理:将“生日”列转换为 datetime 格式 # 3. 预处理:将“生日”列转换为 datetime 格式
df['temp_date'] = pd.to_datetime(df[date_column], errors="coerce") df["temp_date"] = pd.to_datetime(df[date_column], errors="coerce")
# 4. 遍历 DataFrame 并计算/更新数据 # 4. 遍历 DataFrame 并计算/更新数据
for i, row in df.iterrows(): for i, row in df.iterrows():
@@ -458,7 +509,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
progress_callback(i + 1, total_count, "生成属相") progress_callback(i + 1, total_count, "生成属相")
name = row.get("姓名", f"学生_{i + 1}") name = row.get("姓名", f"学生_{i + 1}")
date = row['temp_date'] date = row["temp_date"]
logger.info(f"[{i + 1}/{total_count}] 正在处理学生:{name}...") logger.info(f"[{i + 1}/{total_count}] 正在处理学生:{name}...")
@@ -479,7 +530,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.info(f" -> 属相计算成功:{name} ,属相: {zodiac}") logger.info(f" -> 属相计算成功:{name} ,属相: {zodiac}")
# 6. 清理和保存结果 # 6. 清理和保存结果
df = df.drop(columns=['temp_date']) df = df.drop(columns=["temp_date"])
save_path = excel_path save_path = excel_path
try: try:
@@ -498,13 +549,21 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.error(f"程序运行出错: {str(e)}") logger.error(f"程序运行出错: {str(e)}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
# ========================================== # ==========================================
# 6. 一键生成园长签名(根据输出文件夹生成签名) # 6. 一键生成园长签名(根据输出文件夹生成签名)
# ========================================== # ==========================================
def generate_signature(progress_callback=None) -> str: def generate_signature(progress_callback=None) -> str:
""" """
生成园长签名 生成园长签名(不依赖占位符,直接在指定位置添加)
""" """
# 1. 加载配置文件
try:
config = load_config()
except Exception as e:
logger.error(f"配置文件获取失败: {str(e)}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
try: try:
# 获取所有的PPT (此时返回的是文件名或路径的列表) # 获取所有的PPT (此时返回的是文件名或路径的列表)
pptx_files = get_output_pptx_files(config["output_folder"]) pptx_files = get_output_pptx_files(config["output_folder"])
@@ -521,25 +580,53 @@ def generate_signature(progress_callback=None) -> str:
logger.warning(f"⚠️ 警告: 缺少签名照片('signature'") logger.warning(f"⚠️ 警告: 缺少签名照片('signature'")
return return
logger.info(f"签名图片存在: {img_path}") 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): for i, filename in enumerate(pptx_files):
# 获取完整绝对路径 # 获取完整绝对路径
pptx_path = os.path.join(config["output_folder"], filename) pptx_path = os.path.join(config["output_folder"], filename)
# --- 关键修改点 1: 打开 PPT 对象 --- # 打开 PPT 对象
prs = Presentation(pptx_path) prs = Presentation(pptx_path)
# --- 关键修改点 2: 传递 prs 对象而不是路径字符串 --- # 获取第二张幻灯片 (索引为1)
replace_picture(prs, 1, "signature", img_path) slide = prs.slides[1]
# --- 关键修改点 3: 保存修改后的 PPT --- # 获取修正后的图片流
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) prs.save(pptx_path)
# 更新进度条 (如果有 callback) # 更新进度条 (如果有 callback)
if progress_callback: if progress_callback:
progress_callback(i + 1, len(pptx_files),f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}") progress_callback(
i + 1,
len(pptx_files),
f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}",
)
logger.success(f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}") logger.success(f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}")
if progress_callback: if progress_callback:
progress_callback(len(pptx_files), len(pptx_files), "签名生成完成") progress_callback(len(pptx_files), len(pptx_files), "签名生成完成")
except Exception as e: except Exception as e:
logger.error(f"generate_signature 发生未知错误: {e}") logger.error(f"generate_signature 发生未知错误: {e}")
# 打印详细报错位置,方便调试
logger.error(traceback.format_exc())
return str(e) return str(e)

View File

@@ -1,5 +1,5 @@
from rich.console import Console
from loguru import logger from loguru import logger
from rich.console import Console
from config.config import load_config from config.config import load_config
from utils.pptx_utils import replace_text_in_slide, replace_picture from utils.pptx_utils import replace_text_in_slide, replace_picture
@@ -10,7 +10,7 @@ console = Console()
# ========================================== # ==========================================
# 1. 配置区域 (Configuration) # 1. 配置区域 (Configuration)
# ========================================== # ==========================================
config = load_config("config.toml") config = load_config()
def replace_one_page(prs, name, class_name): def replace_one_page(prs, name, class_name):

View File

@@ -21,28 +21,28 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
original_paragraph_formats = [] original_paragraph_formats = []
for paragraph in shape.text_frame.paragraphs: for paragraph in shape.text_frame.paragraphs:
paragraph_format = { paragraph_format = {
'alignment': paragraph.alignment, "alignment": paragraph.alignment,
'space_before': getattr(paragraph, 'space_before', None), "space_before": getattr(paragraph, "space_before", None),
'space_after': getattr(paragraph, 'space_after', None), "space_after": getattr(paragraph, "space_after", None),
'line_spacing': getattr(paragraph, 'line_spacing', None), "line_spacing": getattr(paragraph, "line_spacing", None),
'left_indent': getattr(paragraph, 'left_indent', None), "left_indent": getattr(paragraph, "left_indent", None),
'right_indent': getattr(paragraph, 'right_indent', None), "right_indent": getattr(paragraph, "right_indent", None),
'first_line_indent': getattr(paragraph, 'first_line_indent', None), "first_line_indent": getattr(paragraph, "first_line_indent", None),
'font_info': [] "font_info": [],
} }
for run in paragraph.runs: for run in paragraph.runs:
run_format = { run_format = {
'font_name': run.font.name, "font_name": run.font.name,
'font_size': run.font.size, "font_size": run.font.size,
'bold': run.font.bold, "bold": run.font.bold,
'italic': run.font.italic, "italic": run.font.italic,
'underline': run.font.underline, "underline": run.font.underline,
'color': run.font.color, "color": run.font.color,
'character_space': getattr(run.font, 'space', None), "character_space": getattr(run.font, "space", None),
'all_caps': getattr(run.font, 'all_caps', None), "all_caps": getattr(run.font, "all_caps", None),
'small_caps': getattr(run.font, 'small_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) original_paragraph_formats.append(paragraph_format)
# 2. 设置新文本 # 2. 设置新文本
@@ -51,49 +51,67 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
# 3. 恢复格式 # 3. 恢复格式
for i, paragraph in enumerate(shape.text_frame.paragraphs): for i, paragraph in enumerate(shape.text_frame.paragraphs):
orig_idx = i if i < len(original_paragraph_formats) else -1 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] original_para = original_paragraph_formats[orig_idx]
# 恢复段落属性 # 恢复段落属性
for attr in ['alignment', 'space_before', 'space_after', 'line_spacing', for attr in [
'left_indent', 'right_indent', 'first_line_indent']: "alignment",
"space_before",
"space_after",
"line_spacing",
"left_indent",
"right_indent",
"first_line_indent",
]:
if original_para[attr] is not None: if original_para[attr] is not None:
setattr(paragraph, attr, original_para[attr]) setattr(paragraph, attr, original_para[attr])
# 恢复字体属性 # 恢复字体属性
for j, run in enumerate(paragraph.runs): for j, run in enumerate(paragraph.runs):
font_idx = j if j < len(original_para['font_info']) else 0 font_idx = j if j < len(original_para["font_info"]) else 0
if not original_para['font_info']: break 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 original_font["font_name"]:
if is_font_available(original_font['font_name']): if is_font_available(original_font["font_name"]):
run.font.name = original_font['font_name'] run.font.name = original_font["font_name"]
else: else:
run.font.name = "微软雅黑" run.font.name = "微软雅黑"
# 恢复其他字体属性 # 恢复其他字体属性
if original_font['font_size']: run.font.size = original_font['font_size'] if original_font["font_size"]:
if original_font['bold']: run.font.bold = original_font['bold'] run.font.size = original_font["font_size"]
if original_font['italic']: run.font.italic = original_font['italic'] if original_font["bold"]:
if original_font['underline']: run.font.underline = original_font['underline'] run.font.bold = original_font["bold"]
if original_font['all_caps']: run.font.all_caps = original_font['all_caps'] 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: try:
run.font.space = original_font['character_space'] run.font.space = original_font["character_space"]
except: except:
pass logger.error(
f"错误: 无法设置字体间距 {original_font['character_space']}"
)
if original_font['color']: if original_font["color"]:
try: try:
if hasattr(original_font['color'], 'rgb'): if hasattr(original_font["color"], "rgb"):
run.font.color.rgb = original_font['color'].rgb run.font.color.rgb = original_font["color"].rgb
except: except:
pass logger.error(
f"错误: 无法设置字体颜色 {original_font['color']}"
)
def replace_picture(prs, slide_index, placeholder, img_path): def replace_picture(prs, slide_index, placeholder, img_path):
@@ -124,8 +142,17 @@ def replace_picture(prs, slide_index, placeholder, img_path):
break break
if target_shape: 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 left, top, width, height = (
target_shape.left,
target_shape.top,
target_shape.width,
target_shape.height,
)
# 2. 获取修正后的图片流 # 2. 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path) img_stream = get_corrected_image_stream(img_path)
@@ -138,3 +165,5 @@ def replace_picture(prs, slide_index, placeholder, img_path):
# 5. 恢复层级位置 (z-order) # 5. 恢复层级位置 (z-order)
sp_tree.insert(target_index, new_shape._element) sp_tree.insert(target_index, new_shape._element)
else:
logger.warning(f"警告: 幻灯片 {slide_index} 中未找到占位符 {placeholder}")

10
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -413,6 +413,7 @@ dependencies = [
{ name = "comtypes" }, { name = "comtypes" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-openai" }, { name = "langchain-openai" },
{ name = "logger" },
{ name = "loguru" }, { name = "loguru" },
{ name = "nicegui" }, { name = "nicegui" },
{ name = "openpyxl" }, { name = "openpyxl" },
@@ -434,6 +435,7 @@ requires-dist = [
{ name = "comtypes", specifier = ">=1.4.13" }, { name = "comtypes", specifier = ">=1.4.13" },
{ name = "langchain", specifier = ">=1.1.3" }, { name = "langchain", specifier = ">=1.1.3" },
{ name = "langchain-openai", specifier = ">=1.1.1" }, { name = "langchain-openai", specifier = ">=1.1.1" },
{ name = "logger" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "nicegui", specifier = ">=3.4.0" }, { name = "nicegui", specifier = ">=3.4.0" },
{ name = "openpyxl", specifier = ">=3.1.5" }, { name = "openpyxl", specifier = ">=3.1.5" },
@@ -742,6 +744,12 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/63/54/4577ef9424debea2fa08af338489d593276520d2e2f8950575d292be612c/langsmith-0.4.59-py3-none-any.whl", hash = "sha256:97c26399286441a7b7b06b912e2801420fbbf3a049787e609d49dc975ab10bc5" }, { url = "https://mirrors.aliyun.com/pypi/packages/63/54/4577ef9424debea2fa08af338489d593276520d2e2f8950575d292be612c/langsmith-0.4.59-py3-none-any.whl", hash = "sha256:97c26399286441a7b7b06b912e2801420fbbf3a049787e609d49dc975ab10bc5" },
] ]
[[package]]
name = "logger"
version = "1.4"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/73/2f/b0d28eaa1e2c1cf64129f8da3fe701888d152677fec708cd0f13e8309e1e/logger-1.4.tar.gz", hash = "sha256:4ecac57133c6376fa215f0fe6b4dc4d60e4d1ad8be005cab4e8a702df682f8b3" }
[[package]] [[package]]
name = "loguru" name = "loguru"
version = "0.7.3" version = "0.7.3"