Compare commits

...

10 Commits

Author SHA1 Message Date
f64f005292 fix:修复一些BUG 2025-12-19 21:37:46 +08:00
842a7cce64 fix:修复一些BUG 2025-12-19 21:37:44 +08:00
14b8c19dfe fix:实现配置功能,实现园长一键签名功能 2025-12-19 12:23:00 +08:00
0e47603d23 fix:修改页面滚动条样式 2025-12-15 11:08:04 +08:00
6809c6f2c6 fix:更新项目说明文档 2025-12-13 22:57:41 +08:00
3a4a9df751 fix:添加niceGui库美化页面 2025-12-13 21:13:25 +08:00
93d1e8687a fix:修复一些BUG 2025-12-13 19:44:27 +08:00
9d347f9bc9 fix: 适配没有英语名字的情况 2025-12-12 17:24:32 +08:00
cbf87d2569 fix:优化模板 2025-12-12 12:41:37 +08:00
7275699c25 fix:优化启动方式 2025-12-12 12:37:41 +08:00
52 changed files with 3064 additions and 1077 deletions

4
.gitignore vendored
View File

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

View File

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

View File

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

4
.idea/misc.xml generated
View File

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

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

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="">
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" 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$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" 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$/main_nicegui.py" beforeDir="false" afterPath="$PROJECT_DIR$/main_nicegui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/ui/views/config_page.py" beforeDir="false" afterPath="$PROJECT_DIR$/ui/views/config_page.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"customColor": "",
"associatedIndex": 0
}]]></component>
<component name="ProjectId" id="3744WiSuPrq64wZVLisMf4zKTFq" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.main_nicegui.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
"git-widget-placeholder": "master",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<configuration name="main_nicegui" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="growth_report" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main_nicegui.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Python.main_nicegui" />
</list>
</recent_temporary>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="" />
<created>1766149044347</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1766149044347</updated>
<workItem from="1766149046808" duration="2127000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<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$" />
</component>
</project>

View File

@@ -2,7 +2,7 @@
## 项目概述 ## 项目概述
这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持双界面运行命令行界面图形界面具备字体安装、图片替换、批量PDF转换、生肖计算等功能。 这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持双界面运行命令行界面图形界面和NiceGUI界面具备字体安装、图片替换、批量PDF转换、生肖计算等功能。
## 技术栈 ## 技术栈
@@ -15,6 +15,7 @@
- **loguru**: 日志记录 - **loguru**: 日志记录
- **tomli**: 配置文件解析 - **tomli**: 配置文件解析
- **tkinter**: 图形用户界面 - **tkinter**: 图形用户界面
- **nicegui**: 现代Web界面
## 核心功能 ## 核心功能
@@ -66,7 +67,9 @@
``` ```
growth_report/ growth_report/
├── main.py # 主程序入口(命令行界面) ├── main.py # 主程序入口(命令行界面)
├── UI.py # 图形用户界面入口 ├── UI.py # 图形用户界面入口tkinter
├── main_nicegui.py # NiceGUI界面入口
├── main.pyw # Windows图形界面启动文件
├── config.env.toml # 项目配置文件 ├── config.env.toml # 项目配置文件
├── pyproject.toml # 项目依赖配置 ├── pyproject.toml # 项目依赖配置
├── start_app.bat # Windows启动批处理文件 ├── start_app.bat # Windows启动批处理文件
@@ -75,6 +78,19 @@ growth_report/
├── config/ ├── config/
│ ├── config.py # 配置加载工具 │ ├── config.py # 配置加载工具
│ └── output/ # 配置输出目录 │ └── output/ # 配置输出目录
├── ui/
│ ├── app_window.py # tkinter图形界面
│ ├── main_nicegui.py # NiceGUI界面主文件
│ ├── assets/
│ │ ├── icon.ico # 应用图标
│ │ └── style.css # 样式文件
│ ├── core/
│ │ ├── logger.py # 日志处理
│ │ ├── state.py # 应用状态管理
│ │ ├── task_runner.py # 任务运行器
│ │ └── __pycache__/
│ └── views/
│ └── home_page.py # NiceGUI主页面
├── utils/ ├── utils/
│ ├── agent_utils.py # AI评语生成工具 │ ├── agent_utils.py # AI评语生成工具
│ ├── file_utils.py # 文件操作工具 │ ├── file_utils.py # 文件操作工具
@@ -82,6 +98,7 @@ growth_report/
│ ├── generate_utils.py # 核心生成功能 │ ├── generate_utils.py # 核心生成功能
│ ├── growt_utils.py # PPT模板替换工具 │ ├── growt_utils.py # PPT模板替换工具
│ ├── image_utils.py # 图片处理工具 │ ├── image_utils.py # 图片处理工具
│ ├── log_handler.py # 日志处理器
│ ├── pdf_utils.py # PDF转换工具 │ ├── pdf_utils.py # PDF转换工具
│ ├── pptx_utils.py # PPT文本和图片替换工具 │ ├── pptx_utils.py # PPT文本和图片替换工具
│ └── zodiac_utils.py # 生肖计算工具 │ └── zodiac_utils.py # 生肖计算工具
@@ -153,11 +170,16 @@ data/images/
python main.py python main.py
``` ```
#### 图形界面 #### 图形界面 (tkinter)
```bash ```bash
python UI.py python UI.py
``` ```
#### NiceGUI界面 (现代Web界面)
```bash
python main_nicegui.py
```
或直接运行批处理文件: 或直接运行批处理文件:
```bash ```bash
start_app.bat start_app.bat
@@ -184,11 +206,11 @@ pip install -r requirements.txt
## 系统特点 ## 系统特点
- **界面支持**: 提供命令行界面和图形界面两种操作方式 - **界面支持**: 提供命令行界面、tkinter图形界面和NiceGUI现代Web界面三种操作方式
- **自动化流程**: 从数据到成品报告的全流程自动化 - **自动化流程**: 从数据到成品报告的全流程自动化
- **AI集成**: 智能生成个性化评语 - **AI集成**: 智能生成个性化评语
- **格式保持**: 替换文本时保持原有格式 - **格式保持**: 替换文本时保持原有格式
- **用户友好**: 丰富的命令行界面和图形界面,实时日志显示 - **用户友好**: 丰富的命令行界面和多种图形界面,实时日志显示
- **批量处理**: 支持批量生成和转换 - **批量处理**: 支持批量生成和转换
- **错误处理**: 完善的异常处理和日志记录 - **错误处理**: 完善的异常处理和日志记录
- **生肖计算**: 自动根据生日计算生肖 - **生肖计算**: 自动根据生日计算生肖
@@ -200,7 +222,7 @@ pip install -r requirements.txt
- 使用`loguru`进行日志记录 - 使用`loguru`进行日志记录
- 使用`rich`美化命令行输出 - 使用`rich`美化命令行输出
- 使用`tkinter`构建图形界面 - 使用`tkinter``nicegui`构建图形界面
- 配置文件使用TOML格式 - 配置文件使用TOML格式
- 图片和文本替换使用占位符机制 - 图片和文本替换使用占位符机制
- 遵循Python代码规范 - 遵循Python代码规范

View File

@@ -5,7 +5,7 @@
## 项目概述 ## 项目概述
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持界面操作(命令行界面和图形界面)具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。 基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统支持UI界面操作具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
## ✨ 主要特性 ## ✨ 主要特性
@@ -13,7 +13,7 @@
- 🤖 **AI评语**: 智能生成个性化、治愈系风格的幼儿评语 - 🤖 **AI评语**: 智能生成个性化、治愈系风格的幼儿评语
- 🖼️ **图文并茂**: 支持个人照片、活动照片、班级合影的自动替换 - 🖼️ **图文并茂**: 支持个人照片、活动照片、班级合影的自动替换
- 📄 **格式转换**: 批量PPT转PDF便于分发和存档 - 📄 **格式转换**: 批量PPT转PDF便于分发和存档
- 🎨 **界面**: 提供命令行界面和图形界面,满足不同用户需求 - 🎨 **界面**: 提供命令行界面、tkinter图形界面和NiceGUI现代Web界面,满足不同用户需求
- 🐲 **生肖计算**: 根据生日自动计算生肖信息 - 🐲 **生肖计算**: 根据生日自动计算生肖信息
- 📦 **模板导出**: 生成标准化数据模板,快速上手 - 📦 **模板导出**: 生成标准化数据模板,快速上手
- 🔤 **字体安装**: 自动检测和安装所需字体文件 - 🔤 **字体安装**: 自动检测和安装所需字体文件
@@ -28,6 +28,7 @@
- **rich**: 美化命令行界面 - **rich**: 美化命令行界面
- **loguru**: 日志记录 - **loguru**: 日志记录
- **tkinter**: 图形用户界面 - **tkinter**: 图形用户界面
- **nicegui**: 现代Web界面
- **tomli**: 配置文件解析 - **tomli**: 配置文件解析
## 📋 系统要求 ## 📋 系统要求
@@ -67,7 +68,12 @@ pip install -r requirements.txt
### 4. 运行程序 ### 4. 运行程序
#### 图形界面(推荐 #### NiceGUI界面推荐现代Web界面
```bash
python main_nicegui.py
```
#### 图形界面tkinter界面
```bash ```bash
python UI.py python UI.py
``` ```
@@ -131,7 +137,9 @@ data/images/
``` ```
growth_report/ growth_report/
├── main.py # 主程序入口(命令行界面) ├── main.py # 主程序入口(命令行界面)
├── UI.py # 图形用户界面入口 ├── UI.py # 图形用户界面入口tkinter
├── main_nicegui.py # NiceGUI界面入口
├── main.pyw # Windows图形界面启动文件
├── config.env.toml # 项目配置文件 ├── config.env.toml # 项目配置文件
├── pyproject.toml # 项目依赖配置 ├── pyproject.toml # 项目依赖配置
├── start_app.bat # 启动脚本 ├── start_app.bat # 启动脚本
@@ -140,6 +148,19 @@ growth_report/
├── config/ ├── config/
│ ├── config.py # 配置加载工具 │ ├── config.py # 配置加载工具
│ └── output/ # 配置输出目录 │ └── output/ # 配置输出目录
├── ui/
│ ├── app_window.py # tkinter图形界面
│ ├── main_nicegui.py # NiceGUI界面主文件
│ ├── assets/
│ │ ├── icon.ico # 应用图标
│ │ └── style.css # 样式文件
│ ├── core/
│ │ ├── logger.py # 日志处理
│ │ ├── state.py # 应用状态管理
│ │ ├── task_runner.py # 任务运行器
│ │ └── __pycache__/
│ └── views/
│ └── home_page.py # NiceGUI主页面
├── utils/ ├── utils/
│ ├── agent_utils.py # AI评语生成工具 │ ├── agent_utils.py # AI评语生成工具
│ ├── file_utils.py # 文件操作工具 │ ├── file_utils.py # 文件操作工具
@@ -147,6 +168,7 @@ growth_report/
│ ├── generate_utils.py # 核心生成功能 │ ├── generate_utils.py # 核心生成功能
│ ├── growt_utils.py # PPT模板替换工具 │ ├── growt_utils.py # PPT模板替换工具
│ ├── image_utils.py # 图片处理工具 │ ├── image_utils.py # 图片处理工具
│ ├── log_handler.py # 日志处理器
│ ├── pdf_utils.py # PDF转换工具 │ ├── pdf_utils.py # PDF转换工具
│ ├── pptx_utils.py # PPT文本和图片替换工具 │ ├── pptx_utils.py # PPT文本和图片替换工具
│ └── zodiac_utils.py # 生肖计算工具 │ └── zodiac_utils.py # 生肖计算工具

225
UI.py
View File

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

View File

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

25
config.toml Normal file
View File

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

BIN
data/signature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

117
main.py
View File

@@ -1,117 +0,0 @@
from config.config import load_config
from utils.generate_utils import (
generate_template,
generate_comment_all,
generate_report,
batch_convert_folder,
generate_zodiac,
)
from utils.file_utils import export_templates_folder, initialize_project, export_data
config = load_config("config.yaml")
def application():
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich.align import Align
from rich import box
import sys
console = Console()
while True:
console.clear()
# 1. 创建一个表格,不显示表头,使用圆角边框
table = Table(box=None, show_header=False, padding=(0, 2))
# 2. 添加两列:序号列(居右),内容列(居左)
table.add_column(justify="right", style="cyan bold")
table.add_column(justify="left")
# 3. 添加行内容
table.add_row("1.", "📁 生成图片路径(每一个幼儿一个图片文件夹)")
table.add_row("2.", "🤖 生成评语(根据姓名、学段、性别)")
table.add_row("3.", "📊 生成报告(根据表格生成)")
table.add_row("4.", "📑 格式转换PPT转PDF")
table.add_row("5.", "📑 生肖转化(根据生日)")
table.add_row("6.", "📦 导出数据模板Zip")
table.add_row("7.", "📦 初始化系统")
table.add_row("8.", "📤 导出数据")
table.add_row("9.", "🚪 退出系统")
# 4. 将表格放入面板,并居中显示
panel = Panel(
Align.center(table),
title="[bold green]🌱 幼儿园成长报告助手",
subtitle="[dim]By 寒寒",
width=60,
border_style="bright_blue",
box=box.ROUNDED, # 圆角边框更柔和
)
# 使用 Align.center 让整个菜单在屏幕中间显示
console.print(Align.center(panel, vertical="middle"))
console.print("\n") # 留点空隙
choice = Prompt.ask(
"👉 请输入序号执行",
choices=["1", "2", "3", "4", "5", "6", "7", "8", "9"],
default="1",
)
try:
if choice == "1":
console.rule("[bold cyan]正在执行: 生成模板[/]")
with console.status(
"[bold green]正在创建文件夹结构...[/]", spinner="dots"
):
generate_template()
elif choice == "2":
console.rule("[bold yellow]正在执行: AI 生成评语[/]")
# 这里的 generate_comment_all 最好内部有进度条,或者简单的 print
generate_comment_all()
elif choice == "3":
console.rule("[bold blue]正在执行: PPT 合成[/]")
with console.status(
"[bold blue]正在处理图片和文字...[/]", spinner="earth"
):
generate_report()
elif choice == "4":
console.rule("[bold magenta]正在执行: PDF 批量转换[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
batch_convert_folder(config["output_folder"])
elif choice == "5":
console.rule("[bold magenta]正在执行: 生肖转化[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
generate_zodiac()
elif choice == "6":
console.rule("[bold magenta]正在执行: 导出数据模板[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
export_templates_folder()
elif choice == "7":
console.rule("[bold magenta]正在执行: 初始化系统[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
initialize_project()
elif choice == "8":
console.rule("[bold magenta]正在执行: 导出数据[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
export_data()
elif choice == "9":
console.print("[bold red]👋 再见![/]")
sys.exit()
Prompt.ask("按 [bold]Enter[/] 键返回主菜单...")
except Exception as e:
console.print(
Panel(
f"[bold red]❌ 发生错误:[/]\n{e}", title="Error", border_style="red"
)
)
Prompt.ask("按 Enter 键继续...")
if __name__ == "__main__":
application()

81
main.pyw Normal file
View File

@@ -0,0 +1,81 @@
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()

39
main.spec Normal file
View File

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

108
main_nicegui.py Normal file
View File

@@ -0,0 +1,108 @@
import os
import sys
from nicegui import ui, app, run, native
from loguru import logger
from screeninfo import get_monitors
import traceback
from config.config import load_config
# 导入我们的模块
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
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# 1. 初始化配置
config = load_config("config.toml")
setup_logger()
# === 关键修改:定义一个获取路径的通用函数 ===
def get_path(relative_path):
"""
获取资源的绝对路径。
兼容:开发环境(直接运行) 和 生产环境(打包成exe后解压的临时目录)
"""
if hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
else:
# 开发环境当前目录
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def calculate_window_size():
"""
获取主屏幕分辨率,并计算一个基于百分比的 NiceGUI 窗口大小。
"""
try:
# 尝试获取所有显示器信息
monitors = get_monitors()
if monitors:
# 假设第一个是主显示器
m = monitors[0]
screen_width = m.width
screen_height = m.height
# 设置窗口宽度为屏幕宽度的 30%
target_width = int(screen_width * 0.30)
# 设置窗口高度为屏幕高度的 60%
target_height = int(screen_height * 0.60)
# 确保窗口有一个合理的最小值 (例如 800x600)
min_width = 800
min_height = 700
target_width = max(target_width, min_width)
target_height = max(target_height, min_height)
logger.info(f"屏幕分辨率: {screen_width}x{screen_height}")
logger.info(f"设置窗口大小为: {target_width}x{target_height}")
return (target_width, target_height)
except Exception as e:
logger.warning(f"无法获取屏幕分辨率 ({e}),使用默认大小 (900, 900)")
return (900, 900) # 失败时的默认值
# 1. 挂载静态资源 (CSS/图片)
# 注意:这里使用 get_path 确保打包后能找到
static_dir = get_path(os.path.join("ui", "assets"))
app.add_static_files('/assets', static_dir)
# 3. 页面路由
@ui.page('/')
def index_page():
create_page()
@ui.page('/config')
def config_page():
create_config_page()
# 4. 启动时钩子
async def startup_check():
try:
logger.info("系统启动: 初始化资源...")
await run.io_bound(install_fonts_from_directory, config["fonts_dir"])
os.makedirs(config["output_folder"], exist_ok=True)
logger.success("资源初始化完成")
except Exception as e:
logger.error(f"初始化失败: {e}")
logger.error(traceback.format_exc())
app.on_startup(startup_check)
if __name__ in {"__main__", "__mp_main__"}:
calculated_size = calculate_window_size()
ui.run(
title="尚城幼儿园成长报告助手",
native=True,
window_size=calculated_size,
port=native.find_open_port(), # 自动寻找端口
reload=False
)

View File

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

BIN
public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

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

112
script/setup.py Normal file
View File

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

107
script/setup_nicegui.py Normal file
View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

236
ui/app_window.py Normal file
View File

@@ -0,0 +1,236 @@
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, "任务进度: 就绪")

BIN
ui/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

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

@@ -0,0 +1,130 @@
/* assets/style.css */
/* 全局字体 */
body {
font-family: "微软雅黑", "Microsoft YaHei", sans-serif;
background-color: #f0f4f8;
}
/* 标题栏 */
.app-header {
background-color: #2E8B57; /* SeaGreen */
color: white;
}
/* 卡片通用样式 */
.func-card {
width: 100%;
padding: 1rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
background-color: white;
border-radius: 0.5rem;
}
/* 核心功能区顶部边框 */
.card-core {
border-top: 4px solid #16a34a; /* green-600 */
}
/* 数据管理区顶部边框 */
.card-data {
border-top: 4px solid #3b82f6; /* blue-500 */
}
/* 系统操作区顶部边框 */
.card-system {
border-top: 4px solid #ef4444; /* red-500 */
}
.card-logging {
border-top: 4px solid #9c1be0;
}
/* 标题文字 */
.section-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
/* 绿色标题 */
.text-green { color: #166534; }
/* 蓝色标题 */
.text-blue { color: #1e40af; }
/* 红色标题 */
.text-red { color: #991b1b; }
/* assets/style.css */
/* ... (原有的样式) ... */
/* ---------------------------------- */
/* 滚动条隐藏样式 */
/* ---------------------------------- */
/*
* 1. 隐藏 Webkit 内核浏览器 (Chrome, Safari, Edge) 的滚动条
* 适用于 NiceGUI 默认的 Chromium 浏览器
*/
.hide-scrollbar::-webkit-scrollbar {
/* 完全隐藏滚动条 */
width: 0px;
background: transparent; /* 使滚动条轨道透明 */
}
/* 2. 隐藏 Firefox 浏览器的滚动条 */
.hide-scrollbar {
/* 设置滚动条宽度为 thin (细),比 auto (默认) 要窄 */
scrollbar-width: none; /* 'none' 是最新且更彻底的隐藏方式 */
/* 确保容器内容溢出时可以滚动 */
overflow: auto;
}
/* 示例:如果你只想隐藏日志区的滚动条 */
.card-logging .q-expansion-item__content .nicegui-log .q-scrollarea__content {
/* 如果 nicegui-log 内部使用了 q-scrollarea可能需要针对其内容应用样式 */
scrollbar-width: none;
}
/* ---------------------------------- */
/* 滚动条美化(可选 - 不隐藏,但变细变淡) */
/* 如果完全隐藏不好,可以试试这个更温和的方案 */
/* ---------------------------------- */
.thin-scrollbar::-webkit-scrollbar {
width: 6px; /* 调整宽度 */
height: 6px; /* 调整高度 */
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #a0a0a0; /* 拇指颜色 */
border-radius: 3px;
border: 1px solid #f0f4f8; /* 边框颜色 */
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar {
scrollbar-width: thin; /* Firefox 细滚动条 */
scrollbar-color: #a0a0a0 transparent; /* Firefox 颜色设置 */
}
*::-webkit-scrollbar {
/* 完全隐藏滚动条 */
width: 0px !important;
height: 0px !important;
background: transparent !important;
}
#nicegui-content, #q-app {
/* 确保容器内容可以滚动,但滚动条被隐藏 */
overflow: auto;
/* Firefox 隐藏滚动条 */
scrollbar-width: none;
/* IE/Edge 隐藏滚动条 */
-ms-overflow-style: none;
}

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

27
utils/log_handler.py Normal file
View File

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

View File

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

View File

@@ -3,7 +3,10 @@
# ========================================== # ==========================================
import os import os
from loguru import logger
from utils.font_utils import is_font_available from utils.font_utils import is_font_available
from utils.image_utils import get_corrected_image_stream
def replace_text_in_slide(prs, slide_index, placeholder, text): def replace_text_in_slide(prs, slide_index, placeholder, text):
@@ -94,18 +97,44 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
def replace_picture(prs, slide_index, placeholder, img_path): def replace_picture(prs, slide_index, placeholder, img_path):
"""在指定幻灯片中替换指定占位符的图片""" """
在指定幻灯片中替换指定占位符的图片(包含自动旋转修复)
参数:
prs: Presentation 对象
slide_index: 幻灯片索引 (从0开始)
placeholder: 占位符名称 (例如 "signature")
img_path: 图片路径
"""
if not os.path.exists(img_path): if not os.path.exists(img_path):
print(f"警告: 图片路径不存在 {img_path}") logger.warning(f"警告: 图片路径不存在 {img_path}")
return return
slide = prs.slides[slide_index] slide = prs.slides[slide_index]
sp_tree = slide.shapes._spTree sp_tree = slide.shapes._spTree
target_shape = None
target_index = -1
# 1. 先找到目标形状和它的索引
for i, shape in enumerate(slide.shapes): for i, shape in enumerate(slide.shapes):
if shape.name == placeholder: if shape.name == placeholder:
left, top, width, height = shape.left, shape.top, shape.width, shape.height target_shape = shape
sp_tree.remove(shape._element) target_index = i
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
sp_tree.insert(i, new_shape._element)
break break
if target_shape:
# 获取原位置信息
left, top, width, height = target_shape.left, target_shape.top, target_shape.width, target_shape.height
# 2. 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path)
# 3. 移除旧形状
sp_tree.remove(target_shape._element)
# 4. 插入新图片 (使用流而不是路径)
new_shape = slide.shapes.add_picture(img_stream, left, top, width, height)
# 5. 恢复层级位置 (z-order)
sp_tree.insert(target_index, new_shape._element)

24
utils/template_utils.py Normal file
View File

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

1185
uv.lock generated

File diff suppressed because it is too large Load Diff