fix:修复一些BUG

This commit is contained in:
2025-12-11 17:52:39 +08:00
parent 4a5672ee62
commit ed4b324dba
11 changed files with 356 additions and 162 deletions

View File

@@ -118,7 +118,7 @@ Excel文件应包含以下列顺序必须与配置文件中一致
data/images/ data/images/
├── 班级名称.jpg # 班级集体照片 ├── 班级名称.jpg # 班级集体照片
└── 学生姓名/ └── 学生姓名/
├── me_image.jpg # 学生个人照片 ├── me.jpg # 学生个人照片
├── 1.jpg # 活动照片1 ├── 1.jpg # 活动照片1
├── 2.jpg # 活动照片2 ├── 2.jpg # 活动照片2
``` ```

154
UI.py
View File

@@ -2,13 +2,14 @@ import tkinter as tk
from tkinter import ttk from tkinter import ttk
from tkinter import scrolledtext from tkinter import scrolledtext
from tkinter import messagebox from tkinter import messagebox
from tkinter import filedialog
import threading import threading
import sys import sys
import time import time
import queue # 【新增】引入队列库 import queue
import re # 【新增】用于去除 ANSI 颜色代码 import re
from loguru import logger # 【新增】需要导入 logger 来配置它 from loguru import logger
from config.config import load_config from config.config import load_config
# 假设你的功能函数都在这里 # 假设你的功能函数都在这里
@@ -19,29 +20,23 @@ from utils.generate_utils import (
generate_report, generate_report,
generate_zodiac, generate_zodiac,
) )
from utils.file_utils import export_data_folder, initialize_project from utils.file_utils import export_templates_folder, initialize_project, export_data
# ========================================== # ==========================================
# 0. 全局配置与队列准备 # 0. 全局配置与队列准备
# ========================================== # ==========================================
config = load_config("config.toml") config = load_config("config.toml")
# 【新增】创建一个全局队列,用于存放日志消息
log_queue = queue.Queue() log_queue = queue.Queue()
def ansi_cleaner(text): def ansi_cleaner(text):
"""【辅助函数】去除 loguru 输出中的 ANSI 颜色代码 (例如 \x1b[32m)""" """【辅助函数】去除 loguru 输出中的 ANSI 颜色代码"""
ansi_escape = re.compile(r"\x1b\[[0-9;]*m") ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
return ansi_escape.sub("", text) return ansi_escape.sub("", text)
def queue_sink(message): def queue_sink(message):
"""【核心】这是一个 loguru sink 回调函数 """【核心】loguru sink 回调"""
当 logger.info() 被调用时loguru 会把格式化好的消息传给这个函数
"""
# message 是一个对象,我们将其转换为字符串并放入队列
# 使用 ansi_cleaner 去掉颜色代码,否则 GUI 里会有乱码
clean_msg = ansi_cleaner(message) clean_msg = ansi_cleaner(message)
log_queue.put(clean_msg) log_queue.put(clean_msg)
@@ -52,103 +47,118 @@ def queue_sink(message):
class ReportApp: class ReportApp:
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
self.root.title("🌱 幼儿园成长报告助手") self.root.title("🌱 尚城幼儿园成长报告助手")
self.root.geometry("700x600") # 稍微调大一点 self.root.geometry("720x680") # 高度稍微增加一点以容纳分组
# 设置样式 # 设置样式
self.style = ttk.Style() self.style = ttk.Style()
self.style.theme_use("clam") self.style.theme_use("clam")
self.style.configure("TButton", font=("微软雅黑", 10), padding=5) self.style.configure("TButton", font=("微软雅黑", 10), padding=5)
self.style.configure( self.style.configure("Title.TLabel", font=("微软雅黑", 16, "bold"), foreground="#2E8B57")
"Title.TLabel", font=("微软雅黑", 16, "bold"), foreground="#2E8B57"
)
self.style.configure("Sub.TLabel", font=("微软雅黑", 9), foreground="gray") self.style.configure("Sub.TLabel", font=("微软雅黑", 9), foreground="gray")
# LabelFrame 的标题样式
self.style.configure("TLabelframe.Label", font=("微软雅黑", 10, "bold"), foreground="#0055a3")
# --- 1. 标题区域 --- # --- 1. 标题区域 ---
header_frame = ttk.Frame(root, padding="10 20 10 10") header_frame = ttk.Frame(root, padding="10 15 10 5")
header_frame.pack(fill=tk.X) header_frame.pack(fill=tk.X)
ttk.Label( ttk.Label(header_frame, text="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack()
header_frame, text="🌱 幼儿园成长报告助手", style="Title.TLabel"
).pack()
ttk.Label(header_frame, text="By 寒寒", style="Sub.TLabel").pack() ttk.Label(header_frame, text="By 寒寒", style="Sub.TLabel").pack()
# --- 2. 按钮功能区域 --- # --- 2. 按钮功能区域 (使用 LabelFrame 分组) ---
btn_frame = ttk.Frame(root, padding=10)
btn_frame.pack(fill=tk.X)
buttons = [ # 容器 Frame给四周留点白
main_content = ttk.Frame(root, padding=10)
main_content.pack(fill=tk.X)
# === A组: 核心功能 ===
func_btns = [
("1. 📁 生成图片路径", self.run_generate_folders), ("1. 📁 生成图片路径", self.run_generate_folders),
("2. 🤖 生成评语 (AI)", self.run_generate_comments), ("2. 🤖 生成评语 (AI)", self.run_generate_comments),
("3. 📊 生成报告 (PPT)", self.run_generate_report), ("3. 📊 生成报告 (PPT)", self.run_generate_report),
("4. 📑 格式转换 (PDF)", self.run_convert_pdf), ("4. 📑 格式转换 (PDF)", self.run_convert_pdf),
("5. 🐂 生肖转化 (生日)", self.run_zodiac), ("5. 🐂 生肖转化 (生日)", self.run_zodiac),
("6. 📦 导出数据模板 (Zip)", self.run_export_data_folder),
("7. 📤 初始化系统", self.run_initialize_project),
("8. 🚪 退出系统", self.quit_app),
] ]
self.create_btn_group(main_content, "🛠️ 核心功能", func_btns, columns=3)
for index, (text, func) in enumerate(buttons): # === B组: 数据导出 ===
btn = ttk.Button(btn_frame, text=text, command=func) export_btns = [
r, c = divmod(index, 2) ("6. 📦 导出数据模板 (Zip)", self.run_export_data_folder),
btn.grid(row=r, column=c, padx=10, pady=5, sticky="ew") ("8. 📤 导出数据备份 (Zip)", self.run_export_data),
]
self.create_btn_group(main_content, "📦 数据管理", export_btns, columns=2)
btn_frame.columnconfigure(0, weight=1) # === C组: 系统设置 ===
btn_frame.columnconfigure(1, weight=1) system_btns = [
("7. ⚠️ 初始化系统 (重置)", self.run_initialize_project),
("9. 🚪 退出系统", self.quit_app),
]
self.create_btn_group(main_content, "⚙️ 系统操作", system_btns, columns=2)
# --- 3. 日志输出区域 --- # --- 3. 日志输出区域 ---
log_frame = ttk.LabelFrame(root, text="系统实时日志", padding=10) log_frame = ttk.LabelFrame(root, text="📝 系统实时日志", padding=10)
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
# 注意:这里字体设为 Consolas 或其他等宽字体,看起来更像代码日志
self.log_text = scrolledtext.ScrolledText( self.log_text = scrolledtext.ScrolledText(
log_frame, height=10, state="disabled", font=("Consolas", 9) log_frame, height=10, state="disabled", font=("Consolas", 9)
) )
self.log_text.pack(fill=tk.BOTH, expand=True) self.log_text.pack(fill=tk.BOTH, expand=True)
# 【新增】启动日志轮询 # 启动日志轮询
# 告诉 GUI每隔 100ms 检查一下队列里有没有新日志
self.root.after(100, self.poll_log_queue) self.root.after(100, self.poll_log_queue)
logger.info("GUI 初始化完成,等待指令...") 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): def poll_log_queue(self):
"""检查队列中是否有新日志,如果有则显示到界面上"""
while not log_queue.empty(): while not log_queue.empty():
try: try:
# 不阻塞地获取消息
msg = log_queue.get_nowait() msg = log_queue.get_nowait()
# 写入 GUI
self.log_text.config(state="normal") self.log_text.config(state="normal")
self.log_text.insert(tk.END, msg) # msg 已经包含了换行符 self.log_text.insert(tk.END, msg)
self.log_text.see(tk.END) # 自动滚动到底部 self.log_text.see(tk.END)
self.log_text.config(state="disabled") self.log_text.config(state="disabled")
except queue.Empty: except queue.Empty:
break break
# 递归调用100ms 后再次执行自己
self.root.after(100, self.poll_log_queue) self.root.after(100, self.poll_log_queue)
# --- 辅助方法:线程包装器 --- # --- 线程包装器 ---
def run_in_thread(self, target_func, *args): def run_in_thread(self, target_func, *args):
"""在单独的线程中运行函数"""
def thread_task(): def thread_task():
try: try:
# 这里不需要手动 self.log 了,因为 target_func 内部的 logger 会自动触发 sink
target_func(*args) target_func(*args)
logger.success("✅ 当前任务执行完毕。") # 使用 logger 输出成功 logger.success("✅ 当前任务执行完毕。")
except Exception as e: except Exception as e:
logger.error(f"❌ 发生错误: {str(e)}") # 使用 logger 输出错误 logger.error(f"❌ 发生错误: {str(e)}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
threading.Thread(target=thread_task, daemon=True).start() threading.Thread(target=thread_task, daemon=True).start()
# ========================================== # ==========================================
# 按钮事件 (不需要改动,逻辑都在 thread_task 里处理了) # 按钮事件 (业务逻辑保持不变)
# ========================================== # ==========================================
def run_generate_folders(self): def run_generate_folders(self):
self.run_in_thread(generate_template) self.run_in_thread(generate_template)
@@ -160,18 +170,36 @@ class ReportApp:
self.run_in_thread(generate_report) self.run_in_thread(generate_report)
def run_convert_pdf(self): def run_convert_pdf(self):
# 传入 output_folder
self.run_in_thread(batch_convert_folder, config["output_folder"]) self.run_in_thread(batch_convert_folder, config["output_folder"])
def run_zodiac(self): def run_zodiac(self):
self.run_in_thread(generate_zodiac) self.run_in_thread(generate_zodiac)
def run_export_data_folder(self): def run_export_data_folder(self):
self.run_in_thread(export_data_folder) 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): def run_initialize_project(self):
self.run_in_thread(initialize_project) 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): def quit_app(self):
if messagebox.askokcancel("退出", "确定要退出系统吗?"): if messagebox.askokcancel("退出", "确定要退出系统吗?"):
self.root.destroy() self.root.destroy()
@@ -182,16 +210,10 @@ class ReportApp:
# 启动入口 # 启动入口
# ========================================== # ==========================================
def applicationUI(): def applicationUI():
# 【核心配置】在启动 GUI 前,配置 loguru
# 1. remove() 移除默认的控制台输出(如果你想保留控制台黑窗口,可以注释掉这行)
# logger.remove()
# 2. 添加我们的自定义 sink (queue_sink)
# format 可以自定义,这里保持简单,或者尽量模拟 loguru 默认格式
logger.add( logger.add(
queue_sink, queue_sink,
format="{time:HH:mm:ss} | {level: <8} | {message}", format="{time:HH:mm:ss} | {level: <8} | {message}",
level="INFO", # 只显示 INFO 及以上级别,避免 DEBUG 信息刷屏 level="INFO",
) )
root = tk.Tk() root = tk.Tk()

View File

@@ -11,6 +11,7 @@ except ImportError:
print("错误: 缺少 TOML 解析库。请运行: pip install tomli") print("错误: 缺少 TOML 解析库。请运行: pip install tomli")
sys.exit(1) sys.exit(1)
def get_main_path(): def get_main_path():
""" """
获取程序运行的根目录 获取程序运行的根目录
@@ -18,7 +19,7 @@ def get_main_path():
1. PyInstaller 打包后的 .exe 环境 1. PyInstaller 打包后的 .exe 环境
2. 开发环境 (假设此脚本在子文件夹中,如 utils/) 2. 开发环境 (假设此脚本在子文件夹中,如 utils/)
""" """
if getattr(sys, 'frozen', False): if getattr(sys, "frozen", False):
# --- 情况 A: 打包后的 exe --- # --- 情况 A: 打包后的 exe ---
# exe 就在根目录下,直接取 exe 所在目录 # exe 就在根目录下,直接取 exe 所在目录
return os.path.dirname(sys.executable) return os.path.dirname(sys.executable)
@@ -36,6 +37,7 @@ def get_main_path():
return project_root return project_root
# ========================================== # ==========================================
# 1. 配置加载 (Config Loader) # 1. 配置加载 (Config Loader)
# ========================================== # ==========================================
@@ -61,7 +63,9 @@ def load_config(config_filename="config.toml"):
# 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录) # 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录)
config = { config = {
"root_path": main_dir, # 方便调试,把根目录也存进去 "root_path": main_dir, # 方便调试,把根目录也存进去
"source_file": os.path.join(main_dir, data["paths"]["source_file"]), "source_file": os.path.join(
main_dir, "templates", data["paths"]["source_file"]
),
"output_folder": os.path.join(main_dir, data["paths"]["output_folder"]), "output_folder": os.path.join(main_dir, data["paths"]["output_folder"]),
"excel_file": os.path.join(main_dir, data["paths"]["excel_file"]), "excel_file": os.path.join(main_dir, data["paths"]["excel_file"]),
"image_folder": os.path.join(main_dir, data["paths"]["image_folder"]), "image_folder": os.path.join(main_dir, data["paths"]["image_folder"]),
@@ -70,7 +74,7 @@ def load_config(config_filename="config.toml"):
"teachers": data["class_info"]["teachers"], "teachers": data["class_info"]["teachers"],
"default_comment": data["defaults"].get("default_comment", "暂无评语"), "default_comment": data["defaults"].get("default_comment", "暂无评语"),
"age_group": data["defaults"].get("age_group", "大班上学期"), "age_group": data["defaults"].get("age_group", "大班上学期"),
"ai": data["ai"] "ai": data["ai"],
} }
return config return config
except Exception as e: except Exception as e:

Binary file not shown.

14
main.py
View File

@@ -5,8 +5,7 @@ from utils.generate_utils import (
batch_convert_folder, batch_convert_folder,
generate_zodiac, generate_zodiac,
) )
from utils.file_utils import export_data_folder, initialize_project from utils.file_utils import export_templates_folder, initialize_project, export_data
from UI import applicationUI
def application(): def application():
@@ -38,7 +37,8 @@ def application():
table.add_row("5.", "📑 生肖转化(根据生日)") table.add_row("5.", "📑 生肖转化(根据生日)")
table.add_row("6.", "📦 导出数据模板Zip") table.add_row("6.", "📦 导出数据模板Zip")
table.add_row("7.", "📦 初始化系统") table.add_row("7.", "📦 初始化系统")
table.add_row("8.", "🚪 退出系统") table.add_row("8.", "📤 导出数据")
table.add_row("9.", "🚪 退出系统")
# 4. 将表格放入面板,并居中显示 # 4. 将表格放入面板,并居中显示
panel = Panel( panel = Panel(
@@ -56,7 +56,7 @@ def application():
choice = Prompt.ask( choice = Prompt.ask(
"👉 请输入序号执行", "👉 请输入序号执行",
choices=["1", "2", "3", "4", "5", "6", "7","8"], choices=["1", "2", "3", "4", "5", "6", "7","8","9"],
default="1", default="1",
) )
@@ -88,12 +88,16 @@ def application():
elif choice == "6": elif choice == "6":
console.rule("[bold magenta]正在执行: 导出数据模板[/]") console.rule("[bold magenta]正在执行: 导出数据模板[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径 # 调用上面的批量转换函数,传入你的 output 文件夹路径
export_data_folder() export_templates_folder()
elif choice == "7": elif choice == "7":
console.rule("[bold magenta]正在执行: 初始化系统[/]") console.rule("[bold magenta]正在执行: 初始化系统[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径 # 调用上面的批量转换函数,传入你的 output 文件夹路径
initialize_project() initialize_project()
elif choice == "8": elif choice == "8":
console.rule("[bold magenta]正在执行: 导出数据[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
export_data()
elif choice == "9":
console.print("[bold red]👋 再见![/]") console.print("[bold red]👋 再见![/]")
sys.exit() sys.exit()
Prompt.ask("按 [bold]Enter[/] 键返回主菜单...") Prompt.ask("按 [bold]Enter[/] 键返回主菜单...")

View File

@@ -13,6 +13,7 @@ dependencies = [
"pandas>=2.3.3", "pandas>=2.3.3",
"pandas-stubs==2.3.3.251201", "pandas-stubs==2.3.3.251201",
"python-pptx>=1.0.2", "python-pptx>=1.0.2",
"pywin32>=311",
"rich>=14.2.0", "rich>=14.2.0",
"tomli>=2.3.0", "tomli>=2.3.0",
] ]

BIN
templates/names K4A.xlsx Normal file

Binary file not shown.

View File

@@ -2,14 +2,16 @@ import shutil
import os import os
import time import time
from loguru import logger from loguru import logger
import zipfile
def export_data_folder(source_folder="data", output_folder="backup"): def export_templates_folder(output_folder="backup"):
""" """
将指定文件夹压缩为 zip 包 将指定文件夹压缩为 zip 包
:param source_folder: 要压缩的文件夹路径 (默认 'data') :param source_folder: 要压缩的文件夹路径 (默认 'data')
:param output_folder: 压缩包存放的文件夹路径 (默认 'backup') :param output_folder: 压缩包存放的文件夹路径 (默认 'backup')
""" """
source_folder = "data"
try: try:
# 1. 检查源文件夹是否存在 # 1. 检查源文件夹是否存在
if not os.path.exists(source_folder): if not os.path.exists(source_folder):
@@ -53,6 +55,76 @@ def export_data_folder(source_folder="data", output_folder="backup"):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def export_data(save_dir, root_dir="."):
"""
导出 data 和 output 两个文件夹到同一个 zip 包中
:param save_dir: 用户在 GUI 弹窗中选择的保存目录 (例如: D:/Backup)
:param root_dir: 项目根目录 (用于找到 data 和 output)
"""
# 1. 定义要打包的目标文件夹
targets = ["data", "output"]
# 2. 检查保存目录
if not os.path.exists(save_dir):
logger.error(f"保存目录不存在: {save_dir}")
return
# 3. 生成压缩包路径
timestamp = time.strftime("%Y%m%d_%H%M%S")
zip_filename = f"完整备份_{timestamp}.zip"
zip_path = os.path.join(save_dir, zip_filename)
logger.info(f"开始备份,目标文件: {zip_path}")
try:
# 4. 创建压缩包 (使用 'w' 写入模式ZIP_DEFLATED 表示压缩)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
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
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 has_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
except Exception as e:
logger.error(f"导出过程出错: {str(e)}")
import traceback
logger.error(traceback.format_exc())
def initialize_project(root_dir="."): def initialize_project(root_dir="."):
""" """
初始化项目:清空 data重建目录复制模板 初始化项目:清空 data重建目录复制模板

View File

@@ -1,5 +1,6 @@
import os import os
import time import time
import pythoncom
import pandas as pd import pandas as pd
from loguru import logger from loguru import logger
@@ -12,7 +13,13 @@ from utils.agent_utils import generate_comment
from utils.font_utils import install_fonts_from_directory from utils.font_utils import install_fonts_from_directory
from utils.image_utils import find_image_path from utils.image_utils import find_image_path
from utils.zodiac_utils import calculate_zodiac from utils.zodiac_utils import calculate_zodiac
from utils.growt_utils import replace_one_page, replace_two_page, replace_three_page, replace_four_page, replace_five_page from utils.growt_utils import (
replace_one_page,
replace_two_page,
replace_three_page,
replace_four_page,
replace_five_page,
)
# 如果你之前没有全局定义 console这里定义一个 # 如果你之前没有全局定义 console这里定义一个
@@ -23,6 +30,7 @@ console = Console()
# ========================================== # ==========================================
config = load_config("config.toml") config = load_config("config.toml")
# ========================================== # ==========================================
# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹) # 1. 生成模板(根据names.xlsx文件生成名字图片文件夹)
# ========================================== # ==========================================
@@ -58,8 +66,10 @@ def generate_template():
logger.error(f"程序运行出错: {str(e)}") logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试 # 打印详细报错位置,方便调试
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
# ========================================== # ==========================================
# 2. 生成评语(根据names.xlsx文件生成评价) # 2. 生成评语(根据names.xlsx文件生成评价)
# ========================================== # ==========================================
@@ -83,20 +93,24 @@ def generate_comment_all():
# 这样我们可以通过索引 i 精准地把数据写回某一行 # 这样我们可以通过索引 i 精准地把数据写回某一行
for i in df.index: for i in df.index:
name = df.at[i, "姓名"] # 获取当前行的姓名 name = df.at[i, "姓名"] # 获取当前行的姓名
sex = df.at[i,"性别"] sex = df.at[i, "性别"]
if pd.isna(sex): if pd.isna(sex):
sex = "" sex = ""
else: else:
sex = str(sex).strip() sex = str(sex).strip()
# 健壮性处理 # 健壮性处理
if pd.isna(name): continue # 跳过空行 if pd.isna(name):
continue # 跳过空行
name = str(name).strip() name = str(name).strip()
# 获取当前行的特征如果Excel里有“特征”这一列就读没有就用默认值 # 获取当前行的特征如果Excel里有“特征”这一列就读没有就用默认值
# 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..." # 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..."
traits = df.at[i, "表现特征"] if "表现特征" in df.columns and not pd.isna( traits = (
df.at[i, "表现特征"]) else "有礼貌、守纪律" df.at[i, "表现特征"]
if "表现特征" in df.columns and not pd.isna(df.at[i, "表现特征"])
else "有礼貌、守纪律"
)
# 优化如果“评价”列已经有内容了跳过不生成节省API费用 # 优化如果“评价”列已经有内容了跳过不生成节省API费用
current_comment = df.at[i, "评价"] current_comment = df.at[i, "评价"]
@@ -109,7 +123,9 @@ def generate_comment_all():
try: try:
# 调用你的生成函数,并【接收返回值】 # 调用你的生成函数,并【接收返回值】
# 注意:这里假设 generate_comment 返回的是清洗后的字符串 # 注意:这里假设 generate_comment 返回的是清洗后的字符串
generated_text = generate_comment(name, config["age_group"], traits,sex) generated_text = generate_comment(
name, config["age_group"], traits, sex
)
# --- 将结果写入 DataFrame --- # --- 将结果写入 DataFrame ---
df.at[i, "评价"] = generated_text df.at[i, "评价"] = generated_text
@@ -136,8 +152,10 @@ def generate_comment_all():
except Exception as e: except Exception as e:
logger.error(f"程序运行出错: {str(e)}") logger.error(f"程序运行出错: {str(e)}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
# ========================================== # ==========================================
# 3. 生成成长报告(根据names.xlsx文件生成) # 3. 生成成长报告(根据names.xlsx文件生成)
# ========================================== # ==========================================
@@ -148,10 +166,11 @@ def generate_report():
time.sleep(2) time.sleep(2)
os.makedirs(config["output_folder"], exist_ok=True) os.makedirs(config["output_folder"], exist_ok=True)
# 检查模版文件是否存在
if not os.path.exists(config["source_file"]): if not os.path.exists(config["source_file"]):
logger.info(f"错误: 找不到模版文件 {config['source_file']}") logger.info(f"错误: 找不到模版文件 {config["source_file"]}")
return return
# 检查数据文件是否存在
if not os.path.exists(config["excel_file"]): if not os.path.exists(config["excel_file"]):
logger.info(f"错误: 找不到数据文件 {config['excel_file']}") logger.info(f"错误: 找不到数据文件 {config['excel_file']}")
return return
@@ -160,8 +179,18 @@ def generate_report():
# 2. 读取数据 # 2. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
# 确保列名对应 # 确保列名对应
columns = ["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物", columns = [
"评价"] "姓名",
"英文名",
"性别",
"生日",
"属相",
"我的好朋友",
"我的爱好",
"喜欢的游戏",
"喜欢吃的食物",
"评价",
]
datas = df[columns].values.tolist() datas = df[columns].values.tolist()
teacher_names_str = " ".join(config["teachers"]) teacher_names_str = " ".join(config["teachers"])
@@ -171,13 +200,23 @@ def generate_report():
# 3. 循环处理 # 3. 循环处理
for i, row_data in enumerate(datas): for i, row_data in enumerate(datas):
# 解包数据 # 解包数据
(name, english_name, sex, birthday, zodiac, friend, hobby, game, food, comments) = row_data (
name,
english_name,
sex,
birthday,
zodiac,
friend,
hobby,
game,
food,
comments,
) = row_data
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}") logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 每次循环重新加载模版 # 每次循环重新加载模版
template_source_file = os.path.join("templates", config["source_file"]) prs = Presentation(config["source_file"])
prs = Presentation(template_source_file)
# --- 页面 1 --- # --- 页面 1 ---
replace_one_page(prs, name, config["class_name"]) replace_one_page(prs, name, config["class_name"])
@@ -187,32 +226,47 @@ def generate_report():
# --- 页面 3 --- # --- 页面 3 ---
student_image_folder = os.path.join(config["image_folder"], name) student_image_folder = os.path.join(config["image_folder"], name)
logger.info(f"学生图片文件夹: {student_image_folder}") logger.info(f"学生{name}图片文件夹: {student_image_folder}")
if os.path.exists(student_image_folder): if os.path.exists(student_image_folder):
me_image_path = find_image_path(student_image_folder, "me_image") me_image_path = find_image_path(student_image_folder, "me")
# 构造信息字典供 helper 使用 # 构造信息字典供 helper 使用
info_dict = { info_dict = {
"name": name, "english_name": english_name, "sex": sex, "name": name,
"birthday": birthday, "zodiac": zodiac, "friend": friend, "english_name": english_name,
"hobby": hobby, "game": game, "food": food "sex": sex,
"birthday": birthday.strftime("%Y-%m-%d") if pd.notna(birthday) else "",
"zodiac": zodiac,
"friend": friend,
"hobby": hobby,
"game": game,
"food": food,
} }
# 逻辑:必须同时满足 "不是None" 且 "是字符串" 且 "文件存在" 才能执行 # 逻辑:必须同时满足 "不是None" 且 "是字符串" 且 "文件存在" 才能执行
if me_image_path and isinstance(me_image_path, str) and os.path.exists(me_image_path): if (
logger.info(f"学生:{name},图片路径有效: {me_image_path},正在替换...") 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) replace_three_page(prs, info_dict, me_image_path)
else: else:
# 只有在这里打印日志,告诉用户跳过了,但不中断程序 # 只有在这里打印日志,告诉用户跳过了,但不中断程序
replace_three_page(prs, info_dict, None) replace_three_page(prs, info_dict, None)
else: else:
logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") logger.warning(f"⚠️ 警告: 学生:{name}学生图片文件夹不存在 {student_image_folder}")
# --- 页面 4 --- # --- 页面 4 ---
class_image_path = find_image_path(config["image_folder"], config["class_name"]) class_image_path = find_image_path(
if os.path.exists(class_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)
):
replace_four_page(prs, class_image_path) replace_four_page(prs, class_image_path)
else: else:
logger.warning(f"错误: 班级图片文件不存在 {class_image_path}") logger.warning(f"⚠️ 警告: 班级图片文件不存在 {class_image_path}")
# --- 页面 5 --- # --- 页面 5 ---
if os.path.exists(student_image_folder): if os.path.exists(student_image_folder):
@@ -228,7 +282,9 @@ def generate_report():
replace_five_page(prs, img1_path, img1_path) replace_five_page(prs, img1_path, img1_path)
# 情况C: 一张都没找到 # 情况C: 一张都没找到
else: else:
logger.warning(f"⚠️ 警告: {name} 缺少作品照片 (1.jpg/png 或 2.jpg/png)[/]") logger.warning(
f"⚠️ 警告: {name} 缺少作品照片 (1.jpg/png 或 2.jpg/png)[/]"
)
else: else:
logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}")
@@ -242,7 +298,9 @@ def generate_report():
prs.save(output_path) prs.save(output_path)
logger.success(f"学生:{name},保存成功: {new_filename}") logger.success(f"学生:{name},保存成功: {new_filename}")
except PermissionError: except PermissionError:
logger.error(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。") logger.error(
f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。"
)
logger.success("所有报告生成完毕!") logger.success("所有报告生成完毕!")
@@ -250,34 +308,45 @@ def generate_report():
logger.error(f"程序运行出错: {str(e)}") logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试 # 打印详细报错位置,方便调试
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
# ========================================== # ==========================================
# 5. 转换格式(根据names.xlsx文件生成PPT转PDF) # 5. 转换格式(根据names.xlsx文件生成PPT转PDF)
# ========================================== # ==========================================
def batch_convert_folder(folder_path): def batch_convert_folder(folder_path):
""" """
【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint速度快) 【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint速度快)
已修复多线程 CoInitialize 报错,并适配 GUI 日志
""" """
# 【核心修复 1】子线程初始化 COM 组件
pythoncom.CoInitialize()
try:
folder_path = os.path.abspath(folder_path) folder_path = os.path.abspath(folder_path)
if not os.path.exists(folder_path): if not os.path.exists(folder_path):
print("文件夹不存在") logger.error(f"文件夹不存在: {folder_path}")
return return
# 获取所有 ppt/pptx 文件 # 获取所有 ppt/pptx 文件
files = [f for f in os.listdir(folder_path) if f.lower().endswith(('.ppt', '.pptx'))] files = [
f for f in os.listdir(folder_path) if f.lower().endswith((".ppt", ".pptx"))
]
if not files: if not files:
print("没有找到 PPT 文件") logger.warning("没有找到 PPT 文件")
return return
print(f"发现 {len(files)} 个文件,准备开始转换...") logger.info(f"发现 {len(files)} 个文件,准备开始转换...")
powerpoint = None powerpoint = None
try: try:
# 1. 启动应用 (只启动一次) # 1. 启动应用 (只启动一次)
powerpoint = comtypes.client.CreateObject("PowerPoint.Application") powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
# 某些环境下需要设为可见,否则无法运行
# 【建议】在后台线程运行时,有时设置为不可见更稳定,
# 但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来
# powerpoint.Visible = 1 # powerpoint.Visible = 1
for filename in files: for filename in files:
@@ -286,27 +355,35 @@ def batch_convert_folder(folder_path):
# 如果 PDF 已存在,可以选择跳过 # 如果 PDF 已存在,可以选择跳过
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
print(f"[跳过] 已存在: {filename}") logger.info(f"[跳过] 已存在: {filename}")
continue continue
print(f"正在转换: {filename} ...") logger.info(f"正在转换: {filename} ...")
try: try:
# 打开 -> 另存为 -> 关闭 # 打开 -> 另存为 -> 关闭
deck = powerpoint.Presentations.Open(ppt_path) deck = powerpoint.Presentations.Open(ppt_path)
deck.SaveAs(pdf_path, 32) deck.SaveAs(pdf_path, 32) # 32 代表 PDF 格式
deck.Close() deck.Close()
except Exception as e: except Exception as e:
print(f"文件 {filename} 转换出错: {e}") logger.error(f"文件 {filename} 转换出错: {e}")
except Exception as e: except Exception as e:
print(f"PowerPoint 进程出错: {e}") logger.error(f"PowerPoint 进程启动出错: {e}")
finally: finally:
# 2. 退出应用 # 2. 退出应用
if powerpoint: if powerpoint:
try:
powerpoint.Quit() powerpoint.Quit()
print("PowerPoint 已关闭,批量转换完成。") except:
pass
logger.success("PowerPoint 已关闭,批量转换完成。")
except Exception as e:
logger.error(f"未知错误: {e}")
finally:
# 【核心修复 2】释放资源
pythoncom.CoUninitialize()
# ========================================== # ==========================================
# 5. 生成属相(根据names.xlsx文件生成属相) # 5. 生成属相(根据names.xlsx文件生成属相)
@@ -319,8 +396,8 @@ def generate_zodiac():
df = pd.read_excel(excel_path, sheet_name="Sheet1") df = pd.read_excel(excel_path, sheet_name="Sheet1")
# 2. 检查必要的列 # 2. 检查必要的列
date_column = '生日' date_column = "生日"
target_column = '属相' target_column = "属相"
if date_column not in df.columns: if date_column not in df.columns:
logger.error(f"Excel中找不到列名{date_column}】,请检查表头。") logger.error(f"Excel中找不到列名{date_column}】,请检查表头。")
@@ -335,7 +412,7 @@ def generate_zodiac():
logger.info(f"开始生成学生属相,共 {total_count} 位学生...") logger.info(f"开始生成学生属相,共 {total_count} 位学生...")
# 3. 数据清洗与计算 # 3. 数据清洗与计算
temp_dates = pd.to_datetime(df[date_column], errors='coerce') temp_dates = pd.to_datetime(df[date_column], errors="coerce")
df[target_column] = temp_dates.apply(calculate_zodiac) df[target_column] = temp_dates.apply(calculate_zodiac)
# 5. 保存结果 # 5. 保存结果
@@ -353,6 +430,5 @@ def generate_zodiac():
except Exception as e: except Exception as e:
logger.error(f"程序运行出错: {str(e)}") logger.error(f"程序运行出错: {str(e)}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())

17
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -122,6 +122,7 @@ dependencies = [
{ name = "pandas" }, { name = "pandas" },
{ name = "pandas-stubs" }, { name = "pandas-stubs" },
{ name = "python-pptx" }, { name = "python-pptx" },
{ name = "pywin32" },
{ name = "rich" }, { name = "rich" },
{ name = "tomli" }, { name = "tomli" },
] ]
@@ -136,6 +137,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.3.3" }, { name = "pandas", specifier = ">=2.3.3" },
{ name = "pandas-stubs", specifier = "==2.3.3.251201" }, { name = "pandas-stubs", specifier = "==2.3.3.251201" },
{ name = "python-pptx", specifier = ">=1.0.2" }, { name = "python-pptx", specifier = ">=1.0.2" },
{ name = "pywin32", specifier = ">=311" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
{ name = "tomli", specifier = ">=2.3.0" }, { name = "tomli", specifier = ">=2.3.0" },
] ]
@@ -859,6 +861,19 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" }, { url = "https://mirrors.aliyun.com/pypi/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" },
] ]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d" },
{ url = "https://mirrors.aliyun.com/pypi/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d" },
{ url = "https://mirrors.aliyun.com/pypi/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a" },
{ url = "https://mirrors.aliyun.com/pypi/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee" },
{ url = "https://mirrors.aliyun.com/pypi/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87" },
{ url = "https://mirrors.aliyun.com/pypi/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"