Compare commits

...

5 Commits

14 changed files with 430 additions and 424 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ data/images/*
data/*.xlsx
.idea/
.trae/

7
.idea/workspace.xml generated
View File

@@ -4,7 +4,9 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="fix优化一些命名规范" />
<list default="true" id="41690157-d51b-4dae-98de-6b96990d681a" name="更改" comment="fix优化一些命名规范">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -95,6 +97,9 @@
<workItem from="1766256207534" duration="960000" />
<workItem from="1766287241685" duration="2135000" />
<workItem from="1766329711762" duration="741000" />
<workItem from="1768312728552" duration="228000" />
<workItem from="1768312972093" duration="486000" />
<workItem from="1768314152581" duration="7000" />
</task>
<task id="LOCAL-00001" summary="fix修复一些BUG">
<option name="closed" value="true" />

102
README.md
View File

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

View File

@@ -4,7 +4,7 @@ output_folder = "output"
excel_file = "names.xlsx"
image_folder = "images"
fonts_dir = "fonts"
signature_image = "C:\\Users\\Administrator\\Desktop\\文档资料\\code\\growth_report\\data\\"
signature_image = "d:\\working\\tools\\growth_report\\data\\signature.png"
[class_info]
class_name = "K4D"

View File

@@ -78,7 +78,7 @@ def load_config(config_filename="config.toml"):
base_dir, paths.get("output_folder", "output")
),
"signature_image": get_resource_path(
os.path.join("data", paths.get("signature_image", ""))
os.path.join("data", paths.get("signature_image", "signature.png"))
),
"class_name": class_info.get("class_name", "未命名班级"),
"teachers": class_info.get("teachers", []),
@@ -118,7 +118,9 @@ def save_config(config_data, config_filename="config.toml"):
"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", ""))
os.path.join(
"data", config_data.get("signature_image", "signature.png")
)
),
},
"class_info": {

View File

@@ -13,6 +13,7 @@ from ui.core.logger import setup_logger
from ui.views.config_page import create_config_page
from ui.views.home_page import create_home_page
from ui.views.data_page import create_data_page
from ui.views.signature_page import create_signature_page
from utils.font_utils import install_fonts_from_directory
sys.stdout.reconfigure(encoding="utf-8")
@@ -95,6 +96,11 @@ def data_page():
create_data_page()
@ui.page("/signature")
def signature_page(folder: str = ""):
create_signature_page(folder)
# 4. 启动时钩子
async def startup_check():
try:

View File

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

View File

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

View File

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

View File

@@ -168,6 +168,7 @@ def create_config_page():
"output_folder": output_folder.value,
"class_name": class_name.value,
"age_group": age_group.value,
"signature_image": conf_data.get("signature_image", ""),
"teachers": [
t.strip() for t in teachers_text.value.split("\n") if t.strip()
],

View File

@@ -1,16 +1,15 @@
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
from ui.core.task_runner import run_task, select_folder
# 导入业务函数
from utils.generate_utils import (
generate_template,
generate_comment_all,
batch_convert_folder,
generate_convert_pdf,
generate_report,
generate_zodiac,
generate_signature,
)
from utils.file_utils import open_folder
@@ -66,16 +65,23 @@ def create_home_page():
f"outline"
).classes("w-full")
# 特殊处理带参数的
async def run_convert():
await run_task(batch_convert_folder, config.get("output_folder"))
func_btn("📁 生成图片路径", generate_template)
func_btn("🤖 生成评语 (AI)", generate_comment_all)
func_btn("📊 生成报告 (PPT)", generate_report)
func_btn("📑 格式转换 (PDF)", run_convert)
func_btn("📑 格式转换 (PDF)", generate_convert_pdf)
func_btn("🐂 生肖转化 (生日)", generate_zodiac)
func_btn("💴 园长一键签名", generate_signature)
# 签名按钮
async def on_signature_click():
selected_folder = await select_folder()
if selected_folder:
ui.navigate.to(f"/signature?folder={selected_folder}")
else:
ui.notify("未选择目录", type="warning")
ui.button("💴 园长签名", on_click=on_signature_click).props(
f"outline"
).classes("w-full")
# === 下方双栏布局 ===
# 数据管理

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

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

View File

@@ -22,13 +22,13 @@ from utils.growt_utils import (
replace_four_page,
replace_five_page,
)
from utils.pptx_utils import replace_picture
# ==========================================
# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹)
# ==========================================
def generate_template(stop_event: threading.Event = None, progress_callback=None):
""""
""" "
根据学生姓名生成相对应的以学生姓名的存放照片的文件夹
:params stop_event 任务是否停止事件监听UI的事件监听
:params progress_callback 进度回调函数
@@ -139,7 +139,9 @@ def generate_comment_all(stop_event: threading.Event = None, progress_callback=N
continue
# 添加进度条
if progress_callback:
progress_callback(i + 1, total_count, f"[{i + 1}/{total_count}] 正在生成评价: {name}")
progress_callback(
i + 1, total_count, f"[{i + 1}/{total_count}] 正在生成评价: {name}"
)
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
try:
@@ -237,7 +239,11 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
) = row_data
# 更新进度条
if progress_callback:
progress_callback(i + 1, total_count, f"[{i + 1}/{len(datas)}] 正在生成: 【{name}】 成长报告")
progress_callback(
i + 1,
total_count,
f"[{i + 1}/{len(datas)}] 正在生成: 【{name}】 成长报告",
)
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 每次循环重新加载模版
@@ -271,11 +277,31 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
"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 " ",
"zodiac": (
str(zodiac).strip()
if str(zodiac).strip() or not str(zodiac).strip().lower()
else " "
),
"friend": (
str(friend).strip()
if str(friend).strip() or not str(friend).strip().lower()
else " "
),
"hobby": (
str(hobby).strip()
if str(hobby).strip() or not str(hobby).strip().lower()
else " "
),
"game": (
str(game).strip()
if str(game).strip() or not str(game).strip().lower()
else " "
),
"food": (
str(food).strip()
if str(food).strip() or not str(food).strip().lower()
else " "
),
}
# 获取学生个人照片路径
me_image_path = find_image_path(student_image_folder, "me")
@@ -343,7 +369,7 @@ def generate_report(stop_event: threading.Event = None, progress_callback=None):
# ==========================================
# 4. 转换格式(根据names.xlsx文件生成PPT转PDF)
# ==========================================
def batch_convert_folder(folder_path, stop_event: threading.Event = None, progress_callback=None):
def generate_convert_pdf(stop_event: threading.Event = None, progress_callback=None):
"""
批量转换文件夹下的所有 PPT
:params folder_path 需要转换的PPT文件夹
@@ -360,7 +386,7 @@ def batch_convert_folder(folder_path, stop_event: threading.Event = None, progre
# 子线程初始化 COM 组件
pythoncom.CoInitialize()
try:
folder_path = os.path.abspath(folder_path)
folder_path = os.path.abspath(config.get("output_folder"))
if not os.path.exists(folder_path):
logger.error(f"文件夹不存在: {folder_path}")
return
@@ -400,7 +426,9 @@ def batch_convert_folder(folder_path, stop_event: threading.Event = None, progre
logger.info(f"[跳过] 已存在: {filename}")
continue
logger.info(f"[{files.index(filename)}/{total_count}]正在转换: {filename} ...")
logger.info(
f"[{files.index(filename)}/{total_count}]正在转换: {filename} ..."
)
try:
# 打开 -> 另存为 -> 关闭
@@ -467,7 +495,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.info(f"开始生成学生属相,共 {total_count} 位学生...")
# 3. 预处理:将“生日”列转换为 datetime 格式
df['temp_date'] = pd.to_datetime(df[date_column], errors="coerce")
df["temp_date"] = pd.to_datetime(df[date_column], errors="coerce")
# 4. 遍历 DataFrame 并计算/更新数据
for i, row in df.iterrows():
@@ -481,7 +509,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
progress_callback(i + 1, total_count, "生成属相")
name = row.get("姓名", f"学生_{i + 1}")
date = row['temp_date']
date = row["temp_date"]
logger.info(f"[{i + 1}/{total_count}] 正在处理学生:{name}...")
@@ -502,7 +530,7 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.info(f" -> 属相计算成功:{name} ,属相: {zodiac}")
# 6. 清理和保存结果
df = df.drop(columns=['temp_date'])
df = df.drop(columns=["temp_date"])
save_path = excel_path
try:
@@ -521,12 +549,13 @@ def generate_zodiac(stop_event: threading.Event = None, progress_callback=None):
logger.error(f"程序运行出错: {str(e)}")
logger.error(traceback.format_exc())
# ==========================================
# 6. 一键生成园长签名(根据输出文件夹生成签名)
# ==========================================
def generate_signature(progress_callback=None) -> str:
"""
生成园长签名
生成园长签名(不依赖占位符,直接在指定位置添加)
"""
# 1. 加载配置文件
try:
@@ -551,22 +580,48 @@ def generate_signature(progress_callback=None) -> str:
logger.warning(f"⚠️ 警告: 缺少签名照片('signature'")
return
logger.info(f"签名图片存在: {img_path}")
# 从配置文件获取签名位置信息,如果没有则使用默认值
signature_left = config.get("signature_left", 2987040) # 左位置
signature_top = config.get("signature_top", 8273415) # 上位置
signature_width = config.get("signature_width", 1800000) # 宽度
signature_height = config.get("signature_height", 720000) # 高度
# 导入必要的模块
from utils.image_utils import get_corrected_image_stream
for i, filename in enumerate(pptx_files):
# 获取完整绝对路径
pptx_path = os.path.join(config["output_folder"], filename)
# --- 关键修改点 1: 打开 PPT 对象 ---
# 打开 PPT 对象
prs = Presentation(pptx_path)
# --- 关键修改点 2: 传递 prs 对象而不是路径字符串 ---
replace_picture(prs, 1, "signature", img_path)
# 获取第二张幻灯片 (索引为1)
slide = prs.slides[1]
# --- 关键修改点 3: 保存修改后的 PPT ---
# 获取修正后的图片流
img_stream = get_corrected_image_stream(img_path)
# 直接在指定位置添加签名图片
slide.shapes.add_picture(
img_stream,
signature_left,
signature_top,
signature_width,
signature_height,
)
logger.info(f"在幻灯片 1 上添加签名图片")
# 保存修改后的 PPT
prs.save(pptx_path)
# 更新进度条 (如果有 callback)
if progress_callback:
progress_callback(i + 1, len(pptx_files),f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}")
progress_callback(
i + 1,
len(pptx_files),
f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}",
)
logger.success(f"[{i + 1}/{len(pptx_files)}] 生成签名完成: {filename}")
if progress_callback:
progress_callback(len(pptx_files), len(pptx_files), "签名生成完成")

View File

@@ -100,14 +100,18 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
try:
run.font.space = original_font["character_space"]
except:
logger.error(f"错误: 无法设置字体间距 {original_font['character_space']}")
logger.error(
f"错误: 无法设置字体间距 {original_font['character_space']}"
)
if original_font["color"]:
try:
if hasattr(original_font["color"], "rgb"):
run.font.color.rgb = original_font["color"].rgb
except:
logger.error(f"错误: 无法设置字体颜色 {original_font['color']}")
logger.error(
f"错误: 无法设置字体颜色 {original_font['color']}"
)
def replace_picture(prs, slide_index, placeholder, img_path):
@@ -138,6 +142,10 @@ def replace_picture(prs, slide_index, placeholder, img_path):
break
if target_shape:
logger.debug(f"找到占位符 {placeholder},索引 {target_index}")
logger.debug(
f"占位符位置: 左 {target_shape.left}, 上 {target_shape.top}, 宽 {target_shape.width}, 高 {target_shape.height}"
)
# 获取原位置信息
left, top, width, height = (
target_shape.left,