Compare commits
10 Commits
4d50c73ecb
...
f64f005292
| Author | SHA1 | Date | |
|---|---|---|---|
| f64f005292 | |||
| 842a7cce64 | |||
| 14b8c19dfe | |||
| 0e47603d23 | |||
| 6809c6f2c6 | |||
| 3a4a9df751 | |||
| 93d1e8687a | |||
| 9d347f9bc9 | |||
| cbf87d2569 | |||
| 7275699c25 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,5 +10,5 @@ wheels/
|
||||
.venv
|
||||
output/*.pptx
|
||||
output/*.pdf
|
||||
|
||||
config.toml
|
||||
data/images/*
|
||||
data/*.xlsx
|
||||
8
.idea/growth_report.iml
generated
8
.idea/growth_report.iml
generated
@@ -1,10 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="uv (growth_report) (2)" jdkType="Python SDK" />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="uv (growth_report)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
@@ -14,7 +12,7 @@
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/templates" />
|
||||
<option value="$MODULE_DIR$/script/dist/尚城幼儿园幼儿学期发展报告/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
56
.idea/inspectionProfiles/Project_Default.xml
generated
56
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -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
4
.idea/misc.xml
generated
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="uv (growth_report) (2)" />
|
||||
<option name="sdkName" value="uv (growth_report)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="uv (growth_report) (2)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="uv (growth_report)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
103
.idea/workspace.xml
generated
Normal file
103
.idea/workspace.xml
generated
Normal 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>
|
||||
34
IFLOW.md
34
IFLOW.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息,结合AI生成个性化评语,并将所有信息批量填充到PPT模板中,最终生成每个学生的个性化成长报告。系统支持双界面运行(命令行界面和图形界面),具备字体安装、图片替换、批量PDF转换、生肖计算等功能。
|
||||
这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息,结合AI生成个性化评语,并将所有信息批量填充到PPT模板中,最终生成每个学生的个性化成长报告。系统支持双界面运行(命令行界面、图形界面和NiceGUI界面),具备字体安装、图片替换、批量PDF转换、生肖计算等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- **loguru**: 日志记录
|
||||
- **tomli**: 配置文件解析
|
||||
- **tkinter**: 图形用户界面
|
||||
- **nicegui**: 现代Web界面
|
||||
|
||||
## 核心功能
|
||||
|
||||
@@ -66,7 +67,9 @@
|
||||
```
|
||||
growth_report/
|
||||
├── main.py # 主程序入口(命令行界面)
|
||||
├── UI.py # 图形用户界面入口
|
||||
├── UI.py # 图形用户界面入口(tkinter)
|
||||
├── main_nicegui.py # NiceGUI界面入口
|
||||
├── main.pyw # Windows图形界面启动文件
|
||||
├── config.env.toml # 项目配置文件
|
||||
├── pyproject.toml # 项目依赖配置
|
||||
├── start_app.bat # Windows启动批处理文件
|
||||
@@ -75,6 +78,19 @@ growth_report/
|
||||
├── config/
|
||||
│ ├── config.py # 配置加载工具
|
||||
│ └── output/ # 配置输出目录
|
||||
├── ui/
|
||||
│ ├── app_window.py # tkinter图形界面
|
||||
│ ├── main_nicegui.py # NiceGUI界面主文件
|
||||
│ ├── assets/
|
||||
│ │ ├── icon.ico # 应用图标
|
||||
│ │ └── style.css # 样式文件
|
||||
│ ├── core/
|
||||
│ │ ├── logger.py # 日志处理
|
||||
│ │ ├── state.py # 应用状态管理
|
||||
│ │ ├── task_runner.py # 任务运行器
|
||||
│ │ └── __pycache__/
|
||||
│ └── views/
|
||||
│ └── home_page.py # NiceGUI主页面
|
||||
├── utils/
|
||||
│ ├── agent_utils.py # AI评语生成工具
|
||||
│ ├── file_utils.py # 文件操作工具
|
||||
@@ -82,6 +98,7 @@ growth_report/
|
||||
│ ├── generate_utils.py # 核心生成功能
|
||||
│ ├── growt_utils.py # PPT模板替换工具
|
||||
│ ├── image_utils.py # 图片处理工具
|
||||
│ ├── log_handler.py # 日志处理器
|
||||
│ ├── pdf_utils.py # PDF转换工具
|
||||
│ ├── pptx_utils.py # PPT文本和图片替换工具
|
||||
│ └── zodiac_utils.py # 生肖计算工具
|
||||
@@ -153,11 +170,16 @@ data/images/
|
||||
python main.py
|
||||
```
|
||||
|
||||
#### 图形界面
|
||||
#### 图形界面 (tkinter)
|
||||
```bash
|
||||
python UI.py
|
||||
```
|
||||
|
||||
#### NiceGUI界面 (现代Web界面)
|
||||
```bash
|
||||
python main_nicegui.py
|
||||
```
|
||||
|
||||
或直接运行批处理文件:
|
||||
```bash
|
||||
start_app.bat
|
||||
@@ -184,11 +206,11 @@ pip install -r requirements.txt
|
||||
|
||||
## 系统特点
|
||||
|
||||
- **双界面支持**: 提供命令行界面和图形界面两种操作方式
|
||||
- **三界面支持**: 提供命令行界面、tkinter图形界面和NiceGUI现代Web界面三种操作方式
|
||||
- **自动化流程**: 从数据到成品报告的全流程自动化
|
||||
- **AI集成**: 智能生成个性化评语
|
||||
- **格式保持**: 替换文本时保持原有格式
|
||||
- **用户友好**: 丰富的命令行界面和图形界面,实时日志显示
|
||||
- **用户友好**: 丰富的命令行界面和多种图形界面,实时日志显示
|
||||
- **批量处理**: 支持批量生成和转换
|
||||
- **错误处理**: 完善的异常处理和日志记录
|
||||
- **生肖计算**: 自动根据生日计算生肖
|
||||
@@ -200,7 +222,7 @@ pip install -r requirements.txt
|
||||
|
||||
- 使用`loguru`进行日志记录
|
||||
- 使用`rich`美化命令行输出
|
||||
- 使用`tkinter`构建图形界面
|
||||
- 使用`tkinter`和`nicegui`构建图形界面
|
||||
- 配置文件使用TOML格式
|
||||
- 图片和文本替换使用占位符机制
|
||||
- 遵循Python代码规范
|
||||
|
||||
30
README.md
30
README.md
@@ -5,7 +5,7 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息,结合AI生成个性化评语,并将所有信息批量填充到PPT模板中,最终生成每个学生的个性化成长报告。系统支持双界面操作(命令行界面和图形界面),具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
|
||||
基于Python的自动化幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息,结合AI生成个性化评语,并将所有信息批量填充到PPT模板中,最终生成每个学生的个性化成长报告。系统支持UI界面操作,具备字体安装、图片替换、批量PDF转换、生肖计算、模板导出等完整功能。
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- 🤖 **AI评语**: 智能生成个性化、治愈系风格的幼儿评语
|
||||
- 🖼️ **图文并茂**: 支持个人照片、活动照片、班级合影的自动替换
|
||||
- 📄 **格式转换**: 批量PPT转PDF,便于分发和存档
|
||||
- 🎨 **双界面**: 提供命令行界面和图形界面,满足不同用户需求
|
||||
- 🎨 **多界面**: 提供命令行界面、tkinter图形界面和NiceGUI现代Web界面,满足不同用户需求
|
||||
- 🐲 **生肖计算**: 根据生日自动计算生肖信息
|
||||
- 📦 **模板导出**: 生成标准化数据模板,快速上手
|
||||
- 🔤 **字体安装**: 自动检测和安装所需字体文件
|
||||
@@ -28,6 +28,7 @@
|
||||
- **rich**: 美化命令行界面
|
||||
- **loguru**: 日志记录
|
||||
- **tkinter**: 图形用户界面
|
||||
- **nicegui**: 现代Web界面
|
||||
- **tomli**: 配置文件解析
|
||||
|
||||
## 📋 系统要求
|
||||
@@ -67,7 +68,12 @@ pip install -r requirements.txt
|
||||
|
||||
### 4. 运行程序
|
||||
|
||||
#### 图形界面(推荐)
|
||||
#### NiceGUI界面(推荐,现代Web界面)
|
||||
```bash
|
||||
python main_nicegui.py
|
||||
```
|
||||
|
||||
#### 图形界面(tkinter界面)
|
||||
```bash
|
||||
python UI.py
|
||||
```
|
||||
@@ -131,7 +137,9 @@ data/images/
|
||||
```
|
||||
growth_report/
|
||||
├── main.py # 主程序入口(命令行界面)
|
||||
├── UI.py # 图形用户界面入口
|
||||
├── UI.py # 图形用户界面入口(tkinter)
|
||||
├── main_nicegui.py # NiceGUI界面入口
|
||||
├── main.pyw # Windows图形界面启动文件
|
||||
├── config.env.toml # 项目配置文件
|
||||
├── pyproject.toml # 项目依赖配置
|
||||
├── start_app.bat # 启动脚本
|
||||
@@ -140,6 +148,19 @@ growth_report/
|
||||
├── config/
|
||||
│ ├── config.py # 配置加载工具
|
||||
│ └── output/ # 配置输出目录
|
||||
├── ui/
|
||||
│ ├── app_window.py # tkinter图形界面
|
||||
│ ├── main_nicegui.py # NiceGUI界面主文件
|
||||
│ ├── assets/
|
||||
│ │ ├── icon.ico # 应用图标
|
||||
│ │ └── style.css # 样式文件
|
||||
│ ├── core/
|
||||
│ │ ├── logger.py # 日志处理
|
||||
│ │ ├── state.py # 应用状态管理
|
||||
│ │ ├── task_runner.py # 任务运行器
|
||||
│ │ └── __pycache__/
|
||||
│ └── views/
|
||||
│ └── home_page.py # NiceGUI主页面
|
||||
├── utils/
|
||||
│ ├── agent_utils.py # AI评语生成工具
|
||||
│ ├── file_utils.py # 文件操作工具
|
||||
@@ -147,6 +168,7 @@ growth_report/
|
||||
│ ├── generate_utils.py # 核心生成功能
|
||||
│ ├── growt_utils.py # PPT模板替换工具
|
||||
│ ├── image_utils.py # 图片处理工具
|
||||
│ ├── log_handler.py # 日志处理器
|
||||
│ ├── pdf_utils.py # PDF转换工具
|
||||
│ ├── pptx_utils.py # PPT文本和图片替换工具
|
||||
│ └── zodiac_utils.py # 生肖计算工具
|
||||
|
||||
225
UI.py
225
UI.py
@@ -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()
|
||||
@@ -4,9 +4,9 @@ source_file = "大班幼儿学期发展报告.pptx"
|
||||
# 输出文件夹
|
||||
output_folder = "output"
|
||||
# Excel数据文件路径
|
||||
excel_file = "data/names.xlsx"
|
||||
excel_file = "names.xlsx"
|
||||
# 图片资源文件夹
|
||||
image_folder = "data/images"
|
||||
image_folder = "images"
|
||||
# 字体文件夹
|
||||
fonts_dir = "fonts"
|
||||
|
||||
|
||||
25
config.toml
Normal file
25
config.toml
Normal 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"
|
||||
147
config/config.py
147
config/config.py
@@ -1,82 +1,121 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 尝试导入 toml 解析库
|
||||
# 1. 处理读取库
|
||||
try:
|
||||
import tomllib as toml # Python 3.11+
|
||||
import tomllib as toml_read # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as toml # pip install tomli
|
||||
import tomli as toml_read
|
||||
except ImportError:
|
||||
print("错误: 缺少 TOML 解析库。请运行: pip install tomli")
|
||||
print("错误: 缺少 TOML 读取库。请运行: pip install tomli")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. 处理写入库 (必须安装 pip install tomli-w)
|
||||
try:
|
||||
import tomli_w as toml_write
|
||||
except ImportError:
|
||||
# 如果没安装,提供一个 fallback 提示
|
||||
toml_write = None
|
||||
|
||||
def get_main_path():
|
||||
"""
|
||||
获取程序运行的根目录
|
||||
兼容:
|
||||
1. PyInstaller 打包后的 .exe 环境
|
||||
2. 开发环境 (假设此脚本在子文件夹中,如 utils/)
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
# --- 情况 A: 打包后的 exe ---
|
||||
# exe 就在根目录下,直接取 exe 所在目录
|
||||
def get_base_dir():
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(sys.executable)
|
||||
else:
|
||||
# --- 情况 B: 开发环境 (.py) ---
|
||||
# 1. 获取当前脚本的绝对路径 (例如: .../MyProject/utils/config_loader.py)
|
||||
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
|
||||
# 假设当前文件在项目根目录或根目录下的某个文件夹中
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def get_resource_path(relative_path):
|
||||
base_path = get_base_dir()
|
||||
external_path = os.path.join(base_path, relative_path)
|
||||
if os.path.exists(external_path):
|
||||
return external_path
|
||||
if getattr(sys, 'frozen', False):
|
||||
internal_path = os.path.join(sys._MEIPASS, relative_path)
|
||||
if os.path.exists(internal_path):
|
||||
return internal_path
|
||||
return external_path
|
||||
|
||||
# ==========================================
|
||||
# 1. 配置加载 (Config Loader)
|
||||
# ==========================================
|
||||
def load_config(config_filename="config.toml"):
|
||||
"""读取 TOML 配置文件"""
|
||||
|
||||
# 1. 先获取正确的根目录
|
||||
main_dir = get_main_path()
|
||||
|
||||
# 2. 拼接配置文件的绝对路径 (防止在不同目录下运行脚本时找不到配置文件)
|
||||
config_path = os.path.join(main_dir, config_filename)
|
||||
config_path = get_resource_path(config_filename)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
print(f"错误: 在路径 {main_dir} 下找不到配置文件 {config_filename}")
|
||||
print(f"尝试寻找的完整路径是: {config_path}")
|
||||
sys.exit(1)
|
||||
# 如果彻底找不到,返回一个最小化的默认值,防止程序奔溃
|
||||
return { "source_file": "", "ai": {"api_key": ""}, "teachers": [] }
|
||||
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
data = toml.load(f)
|
||||
data = toml_read.load(f)
|
||||
|
||||
base_dir = get_base_dir()
|
||||
|
||||
# 使用 .get() 安全获取,防止 KeyError: 'paths'
|
||||
paths = data.get("paths", {})
|
||||
class_info = data.get("class_info", {})
|
||||
defaults = data.get("defaults", {})
|
||||
|
||||
# 将 TOML 的层级结构映射回扁平结构
|
||||
# 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录)
|
||||
config = {
|
||||
"root_path": main_dir, # 方便调试,把根目录也存进去
|
||||
"source_file": os.path.join(
|
||||
main_dir, "templates", data["paths"]["source_file"]
|
||||
),
|
||||
"output_folder": os.path.join(main_dir, data["paths"]["output_folder"]),
|
||||
"excel_file": os.path.join(main_dir, data["paths"]["excel_file"]),
|
||||
"image_folder": os.path.join(main_dir, data["paths"]["image_folder"]),
|
||||
"fonts_dir": os.path.join(main_dir, data["paths"]["fonts_dir"]),
|
||||
"class_name": data["class_info"]["class_name"],
|
||||
"teachers": data["class_info"]["teachers"],
|
||||
"default_comment": data["defaults"].get("default_comment", "暂无评语"),
|
||||
"age_group": data["defaults"].get("age_group", "大班上学期"),
|
||||
"ai": data["ai"],
|
||||
"root_path": base_dir,
|
||||
# 扁平化映射
|
||||
"source_file": get_resource_path(os.path.join("templates", paths.get("source_file", ""))),
|
||||
"excel_file": get_resource_path(os.path.join("data", paths.get("excel_file", ""))),
|
||||
"image_folder": get_resource_path(os.path.join("data", paths.get("image_folder", ""))),
|
||||
"fonts_dir": get_resource_path(paths.get("fonts_dir", "fonts")),
|
||||
"output_folder": os.path.join(base_dir, paths.get("output_folder", "output")),
|
||||
"signature_image": get_resource_path(os.path.join("data", paths.get("signature_image", ""))),
|
||||
|
||||
"class_name": class_info.get("class_name", "未命名班级"),
|
||||
"teachers": class_info.get("teachers", []),
|
||||
"default_comment": defaults.get("default_comment", "暂无评语"),
|
||||
"age_group": defaults.get("age_group", "大班上学期"),
|
||||
"ai": data.get("ai", {"api_key": "", "api_url": "", "model": ""}),
|
||||
}
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
print(f"读取配置文件出错: {e}")
|
||||
sys.exit(1)
|
||||
print(f"解析配置文件失败: {e}")
|
||||
return {}
|
||||
|
||||
# ==========================================
|
||||
# 2. 配置保存 (Config Saver)
|
||||
# ==========================================
|
||||
def save_config(config_data, config_filename="config.toml"):
|
||||
if not toml_write:
|
||||
return False, "未安装 tomli-w 库,无法保存。请运行 pip install tomli-w"
|
||||
|
||||
base_path = get_base_dir()
|
||||
save_path = os.path.join(base_path, config_filename)
|
||||
|
||||
try:
|
||||
# 将扁平化的数据重新打包成嵌套结构,以适配 load_config 的读取逻辑
|
||||
new_data = {
|
||||
"paths": {
|
||||
"source_file": os.path.basename(config_data.get("source_file", "")),
|
||||
"output_folder": os.path.basename(config_data.get("output_folder", "output")),
|
||||
"excel_file": os.path.basename(config_data.get("excel_file", "")),
|
||||
"image_folder": os.path.basename(config_data.get("image_folder", "")),
|
||||
"fonts_dir": os.path.basename(config_data.get("fonts_dir", "fonts")),
|
||||
"signature_image": get_resource_path(os.path.join("data", config_data.get("signature_image", ""))),
|
||||
},
|
||||
"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)}"
|
||||
BIN
data/names.xlsx
BIN
data/names.xlsx
Binary file not shown.
BIN
data/signature.png
Normal file
BIN
data/signature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
117
main.py
117
main.py
@@ -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
81
main.pyw
Normal 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
39
main.spec
Normal 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
108
main_nicegui.py
Normal 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
|
||||
)
|
||||
415
old/main.py
415
old/main.py
@@ -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
BIN
public/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -9,13 +9,19 @@ dependencies = [
|
||||
"langchain>=1.1.3",
|
||||
"langchain-openai>=1.1.1",
|
||||
"loguru>=0.7.3",
|
||||
"nicegui>=3.4.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"pandas>=2.3.3",
|
||||
"pandas-stubs==2.3.3.251201",
|
||||
"pillow>=12.0.0",
|
||||
"pyinstaller>=6.17.0",
|
||||
"python-pptx>=1.0.2",
|
||||
"pywebview>=6.1",
|
||||
"pywin32>=311",
|
||||
"rich>=14.2.0",
|
||||
"screeninfo>=0.8.1",
|
||||
"tomli>=2.3.0",
|
||||
"tomli-w>=1.2.0",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
|
||||
112
script/setup.py
Normal file
112
script/setup.py
Normal 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
107
script/setup_nicegui.py
Normal 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()
|
||||
45
script/尚城幼儿园幼儿学期发展报告.spec
Normal file
45
script/尚城幼儿园幼儿学期发展报告.spec
Normal 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='尚城幼儿园幼儿学期发展报告',
|
||||
)
|
||||
@@ -4,7 +4,7 @@
|
||||
chcp 65001 >nul
|
||||
:: ------------------------------------------------
|
||||
|
||||
title 幼儿园成长报告助手
|
||||
title 幼儿园成长报告助手启动器
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
@@ -13,7 +13,7 @@ echo 正在启动 幼儿园成长报告助手
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
:: 检查 uv 是否安装
|
||||
:: 1. 检查 uv 是否安装
|
||||
uv --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] 未检测到 uv 工具!
|
||||
@@ -22,21 +22,28 @@ if %errorlevel% neq 0 (
|
||||
exit /b
|
||||
)
|
||||
|
||||
echo [INFO] 环境检查通过,正在运行主程序...
|
||||
echo ---------------------------------------------------
|
||||
echo [INFO] 环境检查通过...
|
||||
|
||||
:: 这里的 gui_app.py 就是你刚才保存的那个带界面的 Python 文件名
|
||||
:: 如果你的文件名不一样,请修改下面这一行
|
||||
uv run UI.py
|
||||
|
||||
:: 错误捕获
|
||||
:: 2. 检查依赖是否安装 (可选,防止第一次运行报错)
|
||||
:: 如果你有 pyproject.toml,uv run 会自动处理,这一步可以省略
|
||||
:: 这里为了保险,检查一下 loguru 是否存在,不存在则自动安装基础依赖
|
||||
uv pip show loguru >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ---------------------------------------------------
|
||||
echo [ERROR] 程序异常退出 (代码: %errorlevel%)
|
||||
echo 请检查上方报错信息。
|
||||
pause
|
||||
) else (
|
||||
echo.
|
||||
echo [INFO] 程序已正常结束。
|
||||
echo [INFO] 首次运行,正在安装依赖...
|
||||
uv pip install loguru toml pandas pillow openpyxl python-pptx
|
||||
)
|
||||
|
||||
echo [INFO] 正在拉起主程序...
|
||||
echo ---------------------------------------------------
|
||||
|
||||
:: =======================================================
|
||||
:: 【关键修改】路径改为根目录的 main.pyw
|
||||
:: 使用 start 命令启动,这样黑色的 CMD 窗口可以随后立即关闭
|
||||
:: =======================================================
|
||||
start "" uv run main.pyw
|
||||
|
||||
:: 等待 1 秒确保启动
|
||||
timeout /t 1 >nul
|
||||
|
||||
:: 退出 CMD 窗口 (让用户只看到 GUI)
|
||||
exit
|
||||
Binary file not shown.
Binary file not shown.
BIN
templates/(定制款 横板)中班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(定制款 横板)中班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/(定制款)大班幼儿学期发展报告.pptx
Normal file
BIN
templates/(定制款)大班幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/(横板)中班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(横板)中班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/(横板)大班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(横板)大班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/(横板)小班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(横板)小班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/(竖版)中班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(竖版)中班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
Binary file not shown.
BIN
templates/(竖版)小班 幼儿学期发展报告.pptx
Normal file
BIN
templates/(竖版)小班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
236
ui/app_window.py
Normal file
236
ui/app_window.py
Normal 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
BIN
ui/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
130
ui/assets/style.css
Normal file
130
ui/assets/style.css
Normal 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
15
ui/core/logger.py
Normal 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
13
ui/core/state.py
Normal 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
73
ui/core/task_runner.py
Normal 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
92
ui/views/config_page.py
Normal 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
93
ui/views/home_page.py
Normal 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')
|
||||
@@ -3,15 +3,17 @@ import os
|
||||
import time
|
||||
from loguru import logger
|
||||
import zipfile
|
||||
import traceback
|
||||
|
||||
|
||||
def export_templates_folder(output_folder="backup"):
|
||||
def export_templates_folder(output_folder, stop_event, progress_callback=None):
|
||||
"""
|
||||
将指定文件夹压缩为 zip 包
|
||||
:param source_folder: 要压缩的文件夹路径 (默认 'data')
|
||||
:param output_folder: 压缩包存放的文件夹路径 (默认 'backup')
|
||||
:param stop_event: 停止事件
|
||||
:param progress_callback : 进度条回调
|
||||
"""
|
||||
source_folder = "data"
|
||||
output_folder = output_folder if output_folder else "backup"
|
||||
try:
|
||||
# 1. 检查源文件夹是否存在
|
||||
if not os.path.exists(source_folder):
|
||||
@@ -55,11 +57,12 @@ def export_templates_folder(output_folder="backup"):
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def export_data(save_dir, root_dir="."):
|
||||
def export_data(save_dir, root_dir=".", progress_callback=None):
|
||||
"""
|
||||
导出 data 和 output 两个文件夹到同一个 zip 包中
|
||||
:param save_dir: 用户在 GUI 弹窗中选择的保存目录 (例如: D:/Backup)
|
||||
:param root_dir: 项目根目录 (用于找到 data 和 output)
|
||||
:param progress_callback: 进度条回调函数,接收一个 float (0.0~1.0)
|
||||
"""
|
||||
|
||||
# 1. 定义要打包的目标文件夹
|
||||
@@ -68,7 +71,18 @@ def export_data(save_dir, root_dir="."):
|
||||
# 2. 检查保存目录
|
||||
if not os.path.exists(save_dir):
|
||||
logger.error(f"保存目录不存在: {save_dir}")
|
||||
return
|
||||
return None
|
||||
|
||||
# --- 【新增步骤 A】预先计算文件总数 ---
|
||||
total_files = 0
|
||||
for target in targets:
|
||||
target_abs_path = os.path.join(root_dir, target)
|
||||
if os.path.exists(target_abs_path):
|
||||
for _, _, files in os.walk(target_abs_path):
|
||||
total_files += len(files)
|
||||
|
||||
logger.info(f"待压缩文件总数: {total_files}")
|
||||
# ------------------------------------
|
||||
|
||||
# 3. 生成压缩包路径
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
@@ -78,15 +92,15 @@ def export_data(save_dir, root_dir="."):
|
||||
logger.info(f"开始备份,目标文件: {zip_path}")
|
||||
|
||||
try:
|
||||
# 4. 创建压缩包 (使用 'w' 写入模式,ZIP_DEFLATED 表示压缩)
|
||||
# 4. 创建压缩包
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
|
||||
processed_count = 0 # 当前处理的文件数
|
||||
has_files = False # 标记是否真的压缩了文件
|
||||
|
||||
for target in targets:
|
||||
target_abs_path = os.path.join(root_dir, target)
|
||||
|
||||
# 检查 data 或 output 是否存在
|
||||
if not os.path.exists(target_abs_path):
|
||||
logger.warning(f"⚠️ 跳过: 找不到文件夹 '{target}'")
|
||||
continue
|
||||
@@ -94,26 +108,30 @@ def export_data(save_dir, root_dir="."):
|
||||
logger.info(f"正在压缩: {target} ...")
|
||||
|
||||
# 5. 遍历文件夹写入 ZIP
|
||||
# os.walk 会递归遍历子文件夹
|
||||
for root, dirs, files in os.walk(target_abs_path):
|
||||
for file in files:
|
||||
# 获取文件的绝对路径
|
||||
file_abs_path = os.path.join(root, file)
|
||||
|
||||
# 【关键】计算在压缩包里的相对路径
|
||||
# 例如: D:/Project/data/images/1.jpg -> data/images/1.jpg
|
||||
# 计算相对路径
|
||||
arcname = os.path.relpath(file_abs_path, root_dir)
|
||||
|
||||
# 写入压缩包
|
||||
zf.write(file_abs_path, arcname)
|
||||
has_files = True
|
||||
# 更新进度条
|
||||
if progress_callback:
|
||||
progress_callback(processed_count + 1, total_files, "导出数据中...")
|
||||
|
||||
if has_files:
|
||||
# 确保进度条最后能走到 100%
|
||||
if progress_callback:
|
||||
progress_callback(total_files, total_files, "导出数据成功")
|
||||
|
||||
logger.success(f"✅ 备份成功! 文件已保存至:\n{zip_path}")
|
||||
return zip_path
|
||||
else:
|
||||
logger.error("❌ 备份失败: data 和 output 文件夹均为空或不存在。")
|
||||
# 如果生成了空文件,建议删除
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
return None
|
||||
@@ -121,14 +139,15 @@ def export_data(save_dir, root_dir="."):
|
||||
except Exception as e:
|
||||
logger.error(f"导出过程出错: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return None
|
||||
|
||||
|
||||
def initialize_project(root_dir="."):
|
||||
def initialize_project(root_dir=".", progress_callback=None):
|
||||
"""
|
||||
初始化项目:清空 data,重建目录,复制模板
|
||||
:param root_dir: 项目根目录
|
||||
:param progress_callback : 进度条回调
|
||||
"""
|
||||
# 定义路径
|
||||
data_dir = os.path.join(root_dir, "data")
|
||||
@@ -191,3 +210,34 @@ def initialize_project(root_dir="."):
|
||||
logger.warning(
|
||||
f"⚠️ 警告: 模板文件不存在 ({src_excel}),data 文件夹内将没有 Excel 文件。"
|
||||
)
|
||||
|
||||
|
||||
def check_file_exists(file_path):
|
||||
"""
|
||||
判断文件是否存在
|
||||
"""
|
||||
return file_path and isinstance(file_path, str) and os.path.exists(file_path)
|
||||
|
||||
def get_output_pptx_files(output_dir="output"):
|
||||
"""
|
||||
获取 output 文件夹下所有的 pptx 文件
|
||||
:param output_dir: output 文件夹路径
|
||||
"""
|
||||
try:
|
||||
folder_path = os.path.abspath(output_dir)
|
||||
if not os.path.exists(folder_path):
|
||||
logger.error(f"文件夹不存在: {folder_path}")
|
||||
return
|
||||
# 获取所有 ppt/pptx 文件
|
||||
files = [
|
||||
f for f in os.listdir(folder_path) if f.lower().endswith((".ppt", ".pptx"))
|
||||
]
|
||||
if not files:
|
||||
logger.warning("没有找到 PPT 文件")
|
||||
return
|
||||
total_count = len(files)
|
||||
logger.info(f"发现 {total_count} 个文件,准备开始转换...")
|
||||
return files
|
||||
except Exception as e:
|
||||
logger.error(f"发生未知错误: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from config.config import load_config
|
||||
|
||||
config = load_config("config.toml")
|
||||
|
||||
|
||||
def get_system_fonts():
|
||||
"""获取系统中可用的字体列表"""
|
||||
@@ -21,7 +28,7 @@ def get_system_fonts():
|
||||
for font_file in folder.glob(ext):
|
||||
fonts.add(font_file.stem)
|
||||
except Exception as e:
|
||||
print(f"读取系统字体时出错: {e}")
|
||||
logger.error(f"读取系统字体时出错: {e}")
|
||||
fonts = {"微软雅黑", "宋体", "黑体", "Arial", "Microsoft YaHei"}
|
||||
return fonts
|
||||
|
||||
@@ -40,14 +47,14 @@ def is_font_available(font_name):
|
||||
def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
"""从指定目录安装字体到系统"""
|
||||
if platform.system() != "Windows":
|
||||
print("字体安装功能目前仅支持Windows系统")
|
||||
logger.success("字体安装功能目前仅支持Windows系统")
|
||||
return False
|
||||
|
||||
target_font_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
|
||||
target_font_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not os.path.exists(fonts_dir):
|
||||
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
|
||||
logger.error(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
|
||||
return False
|
||||
|
||||
font_files = []
|
||||
@@ -55,7 +62,7 @@ def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
|
||||
|
||||
if not font_files:
|
||||
print(f"在 {fonts_dir} 目录中未找到字体文件")
|
||||
logger.error(f"在 {fonts_dir} 目录中未找到字体文件")
|
||||
return False
|
||||
|
||||
installed_count = 0
|
||||
@@ -64,12 +71,12 @@ def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
target_path = target_font_dir / font_file.name
|
||||
if not target_path.exists():
|
||||
shutil.copy2(font_file, target_path)
|
||||
print(f"已安装字体: {font_file.name}")
|
||||
logger.success(f"已安装字体: {font_file.name}")
|
||||
installed_count += 1
|
||||
except Exception as e:
|
||||
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
|
||||
logger.error(f"安装字体 {font_file.name} 时出错: {str(e)}")
|
||||
|
||||
if installed_count > 0:
|
||||
print(f"共安装了 {installed_count} 个新字体文件,建议重启Python环境")
|
||||
logger.success(f"共安装了 {installed_count} 个新字体文件,建议重启Python环境")
|
||||
return True
|
||||
return False
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import pythoncom
|
||||
|
||||
@@ -6,11 +7,12 @@ import pandas as pd
|
||||
from loguru import logger
|
||||
from pptx import Presentation
|
||||
from rich.console import Console
|
||||
import traceback
|
||||
|
||||
import comtypes.client
|
||||
from config.config import load_config
|
||||
from utils.agent_utils import generate_comment
|
||||
from utils.font_utils import install_fonts_from_directory
|
||||
from utils.file_utils import check_file_exists, get_output_pptx_files
|
||||
from utils.image_utils import find_image_path
|
||||
from utils.zodiac_utils import calculate_zodiac
|
||||
from utils.growt_utils import (
|
||||
@@ -20,7 +22,7 @@ from utils.growt_utils import (
|
||||
replace_four_page,
|
||||
replace_five_page,
|
||||
)
|
||||
|
||||
from utils.pptx_utils import replace_picture
|
||||
|
||||
# 如果你之前没有全局定义 console,这里定义一个
|
||||
console = Console()
|
||||
@@ -34,26 +36,33 @@ config = load_config("config.toml")
|
||||
# ==========================================
|
||||
# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹)
|
||||
# ==========================================
|
||||
def generate_template():
|
||||
def generate_template(stop_event: threading.Event = None, progress_callback=None):
|
||||
""""
|
||||
根据学生姓名生成相对应的以学生姓名的存放照片的文件夹
|
||||
:params stop_event 任务是否停止事件(监听UI的事件监听)
|
||||
:params progress_callback 进度回调函数
|
||||
"""
|
||||
try:
|
||||
# 2. 读取数据
|
||||
# 1. 读取数据
|
||||
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
|
||||
|
||||
# --- 修改点开始 ---
|
||||
# 直接读取 "姓名" 这一列,不使用列表包裹列名,这样得到的是一维数据
|
||||
# 2. 获取姓名数据
|
||||
datas = df["姓名"].values.tolist()
|
||||
# --- 修改点结束 ---
|
||||
|
||||
logger.info(f"开始生成学生模版文件,共 {len(datas)} 位学生...")
|
||||
|
||||
total_count = len(datas)
|
||||
logger.info(f"开始生成学生模版文件,共 {total_count} 位学生...")
|
||||
# 3. 循环处理
|
||||
# 此时 name 就是字符串 '张三',而不是列表 ['张三']
|
||||
for i, name in enumerate(datas):
|
||||
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
|
||||
# 判断是否有停止事件
|
||||
if stop_event and stop_event.is_set():
|
||||
logger.warning("任务正在停止中,正在中断中.....")
|
||||
return # 停止任务
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total_count, "生成学生图片文件夹")
|
||||
|
||||
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
|
||||
# 确保 name 是字符串且去除了空格 (增加健壮性)
|
||||
name = str(name).strip()
|
||||
|
||||
# 生成学生图片的文件夹
|
||||
student_folder = os.path.join(config["image_folder"], name)
|
||||
|
||||
if os.path.exists(student_folder):
|
||||
@@ -61,19 +70,25 @@ def generate_template():
|
||||
else:
|
||||
logger.info(f"正在生成学生图片文件夹 {student_folder}")
|
||||
os.makedirs(student_folder, exist_ok=True)
|
||||
|
||||
# 更新进度条为100%
|
||||
if progress_callback:
|
||||
progress_callback(total_count, total_count, "生成学生图片文件夹")
|
||||
logger.success("✅ 所有学生模版文件已生成完毕")
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
# 打印详细报错位置,方便调试
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 2. 生成评语(根据names.xlsx文件生成评价)
|
||||
# ==========================================
|
||||
def generate_comment_all():
|
||||
def generate_comment_all(stop_event: threading.Event = None, progress_callback=None):
|
||||
"""
|
||||
根据学生姓名生成评价
|
||||
:params stop_event 任务是否停止事件(监听UI的事件监听)
|
||||
:params progress_callback 进度回调函数
|
||||
"""
|
||||
try:
|
||||
# 1. 读取数据
|
||||
excel_path = config["excel_file"]
|
||||
@@ -83,26 +98,29 @@ def generate_comment_all():
|
||||
if "评价" not in df.columns:
|
||||
df["评价"] = ""
|
||||
|
||||
# --- 获取总行数,用于日志 ---
|
||||
# 强制将“评价”列转换为 object 类型
|
||||
# 获取学生数据行数
|
||||
total_count = len(df)
|
||||
logger.info(f"开始生成学生评语,共 {total_count} 位学生...")
|
||||
|
||||
logger.info(f"开始生成学生评语,共 {len(df)} 位学生...")
|
||||
# 强制将“评价”列转换为 object 类型
|
||||
df["评价"] = df["评价"].astype("object")
|
||||
# --- 遍历 DataFrame 的索引 (index) ---
|
||||
# 这样我们可以通过索引 i 精准地把数据写回某一行
|
||||
for i in df.index:
|
||||
name = df.at[i, "姓名"] # 获取当前行的姓名
|
||||
sex = df.at[i, "性别"]
|
||||
if pd.isna(sex):
|
||||
sex = "男"
|
||||
else:
|
||||
sex = str(sex).strip()
|
||||
|
||||
# 健壮性处理
|
||||
if stop_event and stop_event.is_set():
|
||||
logger.warning("任务正在停止中,正在中断中.....")
|
||||
return # 停止任务
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total_count, "生成学生评语")
|
||||
|
||||
# 获取学生姓名
|
||||
name = df.at[i, "姓名"]
|
||||
if pd.isna(name):
|
||||
continue # 跳过空行
|
||||
continue
|
||||
else:
|
||||
name = str(name).strip()
|
||||
# 获取性别
|
||||
sex = pd.isna(df.at[i, "性别"]) if "男" else str(df.at[i, "性别"]).strip()
|
||||
|
||||
# 获取当前行的特征(如果Excel里有“特征”这一列就读,没有就用默认值)
|
||||
# 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..."
|
||||
@@ -121,64 +139,55 @@ def generate_comment_all():
|
||||
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
|
||||
|
||||
try:
|
||||
# 调用你的生成函数,并【接收返回值】
|
||||
# 注意:这里假设 generate_comment 返回的是清洗后的字符串
|
||||
# 调用AI大模型生成内容
|
||||
generated_text = generate_comment(
|
||||
name, config["age_group"], traits, sex
|
||||
)
|
||||
|
||||
# --- 将结果写入 DataFrame ---
|
||||
df.at[i, "评价"] = generated_text
|
||||
|
||||
df.at[i, "评价"] = generated_text if str(generated_text).strip() else ""
|
||||
logger.success(f"学生:{name},评语生成完毕")
|
||||
|
||||
# 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失
|
||||
# 可选:每生成 5 个就保存一次
|
||||
if (i + 1) % 5 == 0:
|
||||
df.to_excel(excel_path, index=False)
|
||||
logger.info("--- 阶段性保存成功 ---")
|
||||
|
||||
time.sleep(1) # 避免触发API速率限制
|
||||
|
||||
logger.success("✅ 阶段性保存成功")
|
||||
# 避免触发API速率限制
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.error(f"学生:{name},生成评语出错: {str(e)}")
|
||||
|
||||
# --- 修改点 4: 循环结束后最终保存文件 ---
|
||||
# --- 循环结束后最终保存文件 ---
|
||||
# index=False 表示不把 pandas 的索引 (0,1,2...) 写到 Excel 第一列
|
||||
df.to_excel(excel_path, index=False)
|
||||
logger.success(f"所有评语已生成并写入文件:{excel_path}")
|
||||
if progress_callback:
|
||||
progress_callback(total_count, total_count, "生成学生评语")
|
||||
|
||||
except PermissionError:
|
||||
logger.error(f"保存失败!请先关闭 Excel 文件:{config['excel_file']}")
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 3. 生成成长报告(根据names.xlsx文件生成)
|
||||
# ==========================================
|
||||
def generate_report():
|
||||
# 1. 资源准备
|
||||
if install_fonts_from_directory(config["fonts_dir"]):
|
||||
logger.info("等待系统识别新安装的字体...")
|
||||
time.sleep(2)
|
||||
|
||||
os.makedirs(config["output_folder"], exist_ok=True)
|
||||
# 检查模版文件是否存在
|
||||
def generate_report(stop_event: threading.Event = None, progress_callback=None):
|
||||
"""
|
||||
根据学生姓名生成成长报告
|
||||
:params stop_event 任务是否停止事件(监听UI的事件监听)
|
||||
:params progress_callback 进度回调函数
|
||||
"""
|
||||
# 1. 检查模版文件是否存在
|
||||
if not os.path.exists(config["source_file"]):
|
||||
logger.info(f"错误: 找不到模版文件 {config["source_file"]}")
|
||||
return
|
||||
# 检查数据文件是否存在
|
||||
# 2. 检查数据文件是否存在
|
||||
if not os.path.exists(config["excel_file"]):
|
||||
logger.info(f"错误: 找不到数据文件 {config['excel_file']}")
|
||||
return
|
||||
|
||||
try:
|
||||
# 2. 读取数据
|
||||
# 1. 读取数据
|
||||
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
|
||||
# 确保列名对应
|
||||
# 2. 确保列名对应
|
||||
columns = [
|
||||
"姓名",
|
||||
"英文名",
|
||||
@@ -191,14 +200,21 @@ def generate_report():
|
||||
"喜欢吃的食物",
|
||||
"评价",
|
||||
]
|
||||
# 获取数据列表
|
||||
datas = df[columns].values.tolist()
|
||||
total_count = len(datas)
|
||||
|
||||
# 获取配置文件的教师签名
|
||||
teacher_names_str = " ".join(config["teachers"])
|
||||
|
||||
logger.info(f"开始处理,共 {len(datas)} 位学生...")
|
||||
|
||||
logger.info(f"开始处理,共 {total_count} 位学生...")
|
||||
# 3. 循环处理
|
||||
for i, row_data in enumerate(datas):
|
||||
if stop_event and stop_event.is_set():
|
||||
logger.warning("任务正在停止中,正在中断中.....")
|
||||
return
|
||||
# 更新进度条
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total_count, "生成报告")
|
||||
# 解包数据
|
||||
(
|
||||
name,
|
||||
@@ -227,43 +243,47 @@ def generate_report():
|
||||
# --- 页面 3 ---
|
||||
student_image_folder = os.path.join(config["image_folder"], name)
|
||||
logger.info(f"学生:{name},图片文件夹: {student_image_folder}")
|
||||
if os.path.exists(student_image_folder):
|
||||
me_image_path = find_image_path(student_image_folder, "me")
|
||||
# 判断学生图片文件夹是否存在
|
||||
if not os.path.exists(student_image_folder):
|
||||
logger.warning(
|
||||
f"⚠️ 警告: 学生:{name},学生图片文件夹不存在 {student_image_folder}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 构造信息字典供 helper 使用
|
||||
info_dict = {
|
||||
# 检查姓名是否为空
|
||||
if not name:
|
||||
logger.error(f"⚠️ 警告: 学生:{name},姓名为空,跳过")
|
||||
break
|
||||
# 构造学生信息字典
|
||||
student_info_dict = {
|
||||
"name": name,
|
||||
"english_name": english_name,
|
||||
"sex": sex,
|
||||
"birthday": birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else "",
|
||||
"zodiac": zodiac,
|
||||
"friend": friend,
|
||||
"hobby": hobby,
|
||||
"game": game,
|
||||
"food": food,
|
||||
"english_name": english_name if pd.notna(english_name) else " ",
|
||||
"sex": sex if pd.notna(sex) else "男",
|
||||
"birthday": (
|
||||
birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else " "
|
||||
),
|
||||
"zodiac": str(zodiac).strip() if str(zodiac).strip() or not str(zodiac).strip().lower() else " ",
|
||||
"friend": str(friend).strip() if str(friend).strip() or not str(friend).strip().lower() else " ",
|
||||
"hobby": str(hobby).strip() if str(hobby).strip() or not str(hobby).strip().lower() else " ",
|
||||
"game": str(game).strip() if str(game).strip() or not str(game).strip().lower() else " ",
|
||||
"food": str(food).strip() if str(food).strip() or not str(food).strip().lower() else " ",
|
||||
}
|
||||
# 逻辑:必须同时满足 "不是None" 且 "是字符串" 且 "文件存在" 才能执行
|
||||
if (
|
||||
me_image_path
|
||||
and isinstance(me_image_path, str)
|
||||
and os.path.exists(me_image_path)
|
||||
):
|
||||
replace_three_page(prs, info_dict, me_image_path)
|
||||
# 获取学生个人照片路径
|
||||
me_image_path = find_image_path(student_image_folder, "me")
|
||||
# 检查学生图片是否存在,若不存在则跳过
|
||||
if check_file_exists(me_image_path):
|
||||
replace_three_page(prs, student_info_dict, me_image_path)
|
||||
else:
|
||||
# 只有在这里打印日志,告诉用户跳过了,但不中断程序
|
||||
replace_three_page(prs, info_dict, None)
|
||||
else:
|
||||
logger.warning(f"⚠️ 警告: 学生:{name},学生图片文件夹不存在 {student_image_folder}")
|
||||
replace_three_page(prs, student_info_dict)
|
||||
logger.warning(f"⚠️ 警告: 学生图片文件不存在 {me_image_path}")
|
||||
|
||||
# --- 页面 4 ---
|
||||
class_image_path = find_image_path(
|
||||
config["image_folder"], config["class_name"]
|
||||
)
|
||||
if (
|
||||
class_image_path
|
||||
and isinstance(class_image_path, str)
|
||||
and os.path.exists(class_image_path)
|
||||
):
|
||||
|
||||
# 添加检查班级图片是否存在,若不存在则跳过
|
||||
if check_file_exists(class_image_path):
|
||||
replace_four_page(prs, class_image_path)
|
||||
else:
|
||||
logger.warning(f"⚠️ 警告: 班级图片文件不存在 {class_image_path}")
|
||||
@@ -302,27 +322,27 @@ def generate_report():
|
||||
f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。"
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total_count, total_count, "生成报告")
|
||||
logger.success("所有报告生成完毕!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
# 打印详细报错位置,方便调试
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 5. 转换格式(根据names.xlsx文件生成PPT转PDF)
|
||||
# 4. 转换格式(根据names.xlsx文件生成PPT转PDF)
|
||||
# ==========================================
|
||||
def batch_convert_folder(folder_path):
|
||||
def batch_convert_folder(folder_path, stop_event: threading.Event = None, progress_callback=None):
|
||||
"""
|
||||
【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint,速度快)
|
||||
已修复多线程 CoInitialize 报错,并适配 GUI 日志
|
||||
批量转换文件夹下的所有 PPT
|
||||
:params folder_path 需要转换的PPT文件夹
|
||||
:params stop_event 任务是否停止事件(监听UI的事件监听)
|
||||
:params progress_callback 进度回调函数
|
||||
"""
|
||||
# 【核心修复 1】子线程初始化 COM 组件
|
||||
# 子线程初始化 COM 组件
|
||||
pythoncom.CoInitialize()
|
||||
|
||||
try:
|
||||
folder_path = os.path.abspath(folder_path)
|
||||
if not os.path.exists(folder_path):
|
||||
@@ -338,18 +358,24 @@ def batch_convert_folder(folder_path):
|
||||
logger.warning("没有找到 PPT 文件")
|
||||
return
|
||||
|
||||
logger.info(f"发现 {len(files)} 个文件,准备开始转换...")
|
||||
total_count = len(files)
|
||||
logger.info(f"发现 {total_count} 个文件,准备开始转换...")
|
||||
|
||||
powerpoint = None
|
||||
try:
|
||||
# 1. 启动应用 (只启动一次)
|
||||
powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
|
||||
|
||||
# 【建议】在后台线程运行时,有时设置为不可见更稳定,
|
||||
# 但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来
|
||||
# 设置是否显示转化页面,但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来
|
||||
# powerpoint.Visible = 1
|
||||
|
||||
for filename in files:
|
||||
if stop_event and stop_event.is_set():
|
||||
logger.warning("任务正在停止中,正在中断中.....")
|
||||
return
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(files.index(filename), total_count, "转换PDF")
|
||||
ppt_path = os.path.join(folder_path, filename)
|
||||
pdf_path = os.path.splitext(ppt_path)[0] + ".pdf"
|
||||
|
||||
@@ -358,7 +384,7 @@ def batch_convert_folder(folder_path):
|
||||
logger.info(f"[跳过] 已存在: {filename}")
|
||||
continue
|
||||
|
||||
logger.info(f"正在转换: {filename} ...")
|
||||
logger.info(f"[{files.index(filename)}/{total_count}]正在转换: {filename} ...")
|
||||
|
||||
try:
|
||||
# 打开 -> 另存为 -> 关闭
|
||||
@@ -368,6 +394,9 @@ def batch_convert_folder(folder_path):
|
||||
except Exception as e:
|
||||
logger.error(f"文件 {filename} 转换出错: {e}")
|
||||
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(total_count, total_count, "转换PDF")
|
||||
except Exception as e:
|
||||
logger.error(f"PowerPoint 进程启动出错: {e}")
|
||||
finally:
|
||||
@@ -385,14 +414,19 @@ def batch_convert_folder(folder_path):
|
||||
# 【核心修复 2】释放资源
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 5. 生成属相(根据names.xlsx文件生成属相)
|
||||
# ==========================================
|
||||
def generate_zodiac():
|
||||
def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
|
||||
"""
|
||||
生成学生属相,如果“生日”列为空,则跳过该学生。
|
||||
:params stop_event 任务是否停止事件(监听UI的事件监听)
|
||||
:params progress_callback 进度回调函数
|
||||
"""
|
||||
try:
|
||||
# 1. 读取数据
|
||||
excel_path = config["excel_file"]
|
||||
# sheet_name 根据实际情况修改,如果不确定可以用 sheet_name=0 读取第一个
|
||||
df = pd.read_excel(excel_path, sheet_name="Sheet1")
|
||||
|
||||
# 2. 检查必要的列
|
||||
@@ -403,32 +437,109 @@ def generate_zodiac():
|
||||
logger.error(f"Excel中找不到列名:【{date_column}】,请检查表头。")
|
||||
return
|
||||
|
||||
# 检查是否存在"属相"列,不存在则新建
|
||||
if target_column not in df.columns:
|
||||
df[target_column] = ""
|
||||
|
||||
# --- 获取总行数,用于日志 ---
|
||||
total_count = len(df)
|
||||
logger.info(f"开始生成学生属相,共 {total_count} 位学生...")
|
||||
|
||||
# 3. 数据清洗与计算
|
||||
temp_dates = pd.to_datetime(df[date_column], errors="coerce")
|
||||
df[target_column] = temp_dates.apply(calculate_zodiac)
|
||||
# 3. 预处理:将“生日”列转换为 datetime 格式
|
||||
df['temp_date'] = pd.to_datetime(df[date_column], errors="coerce")
|
||||
|
||||
# 4. 遍历 DataFrame 并计算/更新数据
|
||||
for i, row in df.iterrows():
|
||||
|
||||
# 关键点 1: 检查停止信号
|
||||
if stop_event and stop_event.is_set():
|
||||
logger.warning("任务已接收到停止信号,正在中断...")
|
||||
return
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total_count, "生成属相")
|
||||
|
||||
name = row.get("姓名", f"学生_{i + 1}")
|
||||
date = row['temp_date']
|
||||
|
||||
logger.info(f"[{i + 1}/{total_count}] 正在处理学生:{name}...")
|
||||
|
||||
# === 关键点 2: 检查生日是否为空 ===
|
||||
if pd.isna(date):
|
||||
# 记录警告日志并跳过当前循环迭代
|
||||
logger.warning(f"跳过:学生【{name}】的生日数据为空或格式错误。")
|
||||
# 可以选择将属相字段清空或设置为特定值,此处设置为“待补充”
|
||||
df.loc[i, target_column] = "待补充"
|
||||
continue # 跳到下一个学生
|
||||
# =================================
|
||||
|
||||
# 5. 计算并赋值
|
||||
zodiac = calculate_zodiac(date)
|
||||
|
||||
df.loc[i, target_column] = zodiac
|
||||
|
||||
logger.info(f" -> 属相计算成功:{name} ,属相: {zodiac}")
|
||||
|
||||
# 6. 清理和保存结果
|
||||
df = df.drop(columns=['temp_date'])
|
||||
|
||||
# 5. 保存结果
|
||||
save_path = excel_path
|
||||
|
||||
try:
|
||||
df.to_excel(save_path, index=False)
|
||||
df.to_excel(save_path, sheet_name="Sheet1", index=False)
|
||||
logger.success(f"所有属相已更新并写入文件:{save_path}")
|
||||
logger.warning(f"请检查文件 {save_path} 修改日期格式。")
|
||||
except PermissionError:
|
||||
logger.error(f"保存失败!请先关闭 Excel 文件:{save_path}")
|
||||
# 添加进度条
|
||||
if progress_callback:
|
||||
progress_callback(total_count, total_count, "生成属相")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"找不到文件 {config.get('excel_file')}")
|
||||
logger.error(traceback.format_exc())
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# ==========================================
|
||||
# 6. 一键生成园长签名(根据输出文件夹生成签名)
|
||||
# ==========================================
|
||||
def generate_signature(progress_callback=None) -> str:
|
||||
"""
|
||||
生成园长签名
|
||||
"""
|
||||
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)
|
||||
@@ -11,6 +11,8 @@ console = Console()
|
||||
# 1. 配置区域 (Configuration)
|
||||
# ==========================================
|
||||
config = load_config("config.toml")
|
||||
|
||||
|
||||
def replace_one_page(prs, name, class_name):
|
||||
"""替换第一页信息"""
|
||||
replace_text_in_slide(prs, 0, "name", name)
|
||||
@@ -23,7 +25,7 @@ def replace_two_page(prs, comments, teacher_name):
|
||||
replace_text_in_slide(prs, 1, "teacher_name", teacher_name)
|
||||
|
||||
|
||||
def replace_three_page(prs, info_dict, me_image):
|
||||
def replace_three_page(prs, info_dict, me_image=None):
|
||||
"""替换第三页信息"""
|
||||
# 使用字典解包传递多个字段,减少参数数量
|
||||
fields = ["name", "english_name", "sex", "birthday", "zodiac", "friend", "hobby", "game", "food"]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
from PIL import Image, ExifTags
|
||||
import io
|
||||
|
||||
|
||||
def find_image_path(folder, base_filename):
|
||||
@@ -26,3 +28,39 @@ def find_image_path(folder, base_filename):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_corrected_image_stream(img_path):
|
||||
"""
|
||||
读取图片,根据 EXIF 信息修正旋转方向,并返回 BytesIO 对象。
|
||||
这样不需要修改原文件,直接在内存中处理。
|
||||
"""
|
||||
image = Image.open(img_path)
|
||||
|
||||
# 获取 EXIF 数据
|
||||
try:
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
if ExifTags.TAGS[orientation] == 'Orientation':
|
||||
break
|
||||
|
||||
exif = dict(image._getexif().items())
|
||||
|
||||
if exif[orientation] == 3:
|
||||
image = image.rotate(180, expand=True)
|
||||
elif exif[orientation] == 6:
|
||||
image = image.rotate(270, expand=True)
|
||||
elif exif[orientation] == 8:
|
||||
image = image.rotate(90, expand=True)
|
||||
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
# 如果图片没有 EXIF 数据或不需要旋转,则忽略
|
||||
pass
|
||||
|
||||
# 将处理后的图片保存到内存流中
|
||||
image_stream = io.BytesIO()
|
||||
# 注意:保存时保持原格式,如果是 PNG 等无 EXIF 的格式会自动处理
|
||||
img_format = image.format if image.format else 'JPEG'
|
||||
image.save(image_stream, format=img_format)
|
||||
image_stream.seek(0) # 指针回到开头
|
||||
|
||||
return image_stream
|
||||
27
utils/log_handler.py
Normal file
27
utils/log_handler.py
Normal 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")
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
|
||||
import pythoncom
|
||||
import comtypes.client
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ def ppt_to_pdf_single(ppt_path, pdf_path=None):
|
||||
:param ppt_path: PPT 文件路径
|
||||
:param pdf_path: PDF 输出路径 (可选,默认同名)
|
||||
"""
|
||||
# 子线程初始化 COM 组件
|
||||
pythoncom.CoInitialize()
|
||||
ppt_path = os.path.abspath(ppt_path) # COM 接口必须使用绝对路径
|
||||
|
||||
if pdf_path is None:
|
||||
@@ -40,3 +42,4 @@ def ppt_to_pdf_single(ppt_path, pdf_path=None):
|
||||
finally:
|
||||
if powerpoint:
|
||||
powerpoint.Quit()
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
# ==========================================
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from utils.font_utils import is_font_available
|
||||
from utils.image_utils import get_corrected_image_stream
|
||||
|
||||
|
||||
def replace_text_in_slide(prs, slide_index, placeholder, text):
|
||||
@@ -94,18 +97,44 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
|
||||
|
||||
|
||||
def replace_picture(prs, slide_index, placeholder, img_path):
|
||||
"""在指定幻灯片中替换指定占位符的图片"""
|
||||
"""
|
||||
在指定幻灯片中替换指定占位符的图片(包含自动旋转修复)
|
||||
|
||||
参数:
|
||||
prs: Presentation 对象
|
||||
slide_index: 幻灯片索引 (从0开始)
|
||||
placeholder: 占位符名称 (例如 "signature")
|
||||
img_path: 图片路径
|
||||
"""
|
||||
if not os.path.exists(img_path):
|
||||
print(f"警告: 图片路径不存在 {img_path}")
|
||||
logger.warning(f"警告: 图片路径不存在 {img_path}")
|
||||
return
|
||||
|
||||
slide = prs.slides[slide_index]
|
||||
sp_tree = slide.shapes._spTree
|
||||
|
||||
target_shape = None
|
||||
target_index = -1
|
||||
|
||||
# 1. 先找到目标形状和它的索引
|
||||
for i, shape in enumerate(slide.shapes):
|
||||
if shape.name == placeholder:
|
||||
left, top, width, height = shape.left, shape.top, shape.width, shape.height
|
||||
sp_tree.remove(shape._element)
|
||||
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
|
||||
sp_tree.insert(i, new_shape._element)
|
||||
target_shape = shape
|
||||
target_index = i
|
||||
break
|
||||
|
||||
if target_shape:
|
||||
# 获取原位置信息
|
||||
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
24
utils/template_utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user