diff --git a/IFLOW.md b/IFLOW.md index 5471274..012afc1 100644 --- a/IFLOW.md +++ b/IFLOW.md @@ -118,7 +118,7 @@ Excel文件应包含以下列(顺序必须与配置文件中一致): data/images/ ├── 班级名称.jpg # 班级集体照片 └── 学生姓名/ - ├── me_image.jpg # 学生个人照片 + ├── me.jpg # 学生个人照片 ├── 1.jpg # 活动照片1 ├── 2.jpg # 活动照片2 ``` diff --git a/UI.py b/UI.py index 043c78c..f18697a 100644 --- a/UI.py +++ b/UI.py @@ -2,13 +2,14 @@ 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 # 【新增】用于去除 ANSI 颜色代码 +import queue +import re -from loguru import logger # 【新增】需要导入 logger 来配置它 +from loguru import logger from config.config import load_config # 假设你的功能函数都在这里 @@ -19,29 +20,23 @@ from utils.generate_utils import ( generate_report, 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. 全局配置与队列准备 # ========================================== config = load_config("config.toml") - -# 【新增】创建一个全局队列,用于存放日志消息 log_queue = queue.Queue() def ansi_cleaner(text): - """【辅助函数】去除 loguru 输出中的 ANSI 颜色代码 (例如 \x1b[32m)""" + """【辅助函数】去除 loguru 输出中的 ANSI 颜色代码""" ansi_escape = re.compile(r"\x1b\[[0-9;]*m") return ansi_escape.sub("", text) def queue_sink(message): - """【核心】这是一个 loguru 的 sink 回调函数 - 当 logger.info() 被调用时,loguru 会把格式化好的消息传给这个函数 - """ - # message 是一个对象,我们将其转换为字符串并放入队列 - # 使用 ansi_cleaner 去掉颜色代码,否则 GUI 里会有乱码 + """【核心】loguru sink 回调""" clean_msg = ansi_cleaner(message) log_queue.put(clean_msg) @@ -52,103 +47,118 @@ def queue_sink(message): class ReportApp: def __init__(self, root): self.root = root - self.root.title("🌱 幼儿园成长报告助手") - self.root.geometry("700x600") # 稍微调大一点 + 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("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 20 10 10") + 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="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack() ttk.Label(header_frame, text="By 寒寒", style="Sub.TLabel").pack() - # --- 2. 按钮功能区域 --- - btn_frame = ttk.Frame(root, padding=10) - btn_frame.pack(fill=tk.X) + # --- 2. 按钮功能区域 (使用 LabelFrame 分组) --- + + # 容器 Frame,给四周留点白 + main_content = ttk.Frame(root, padding=10) + main_content.pack(fill=tk.X) - buttons = [ + # === A组: 核心功能 === + func_btns = [ ("1. 📁 生成图片路径", self.run_generate_folders), ("2. 🤖 生成评语 (AI)", self.run_generate_comments), ("3. 📊 生成报告 (PPT)", self.run_generate_report), ("4. 📑 格式转换 (PDF)", self.run_convert_pdf), ("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): - btn = ttk.Button(btn_frame, text=text, command=func) - r, c = divmod(index, 2) - btn.grid(row=r, column=c, padx=10, pady=5, sticky="ew") + # === B组: 数据导出 === + export_btns = [ + ("6. 📦 导出数据模板 (Zip)", self.run_export_data_folder), + ("8. 📤 导出数据备份 (Zip)", self.run_export_data), + ] + self.create_btn_group(main_content, "📦 数据管理", export_btns, columns=2) - btn_frame.columnconfigure(0, weight=1) - btn_frame.columnconfigure(1, weight=1) + # === C组: 系统设置 === + system_btns = [ + ("7. ⚠️ 初始化系统 (重置)", self.run_initialize_project), + ("9. 🚪 退出系统", 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=10) + log_frame = ttk.LabelFrame(root, text="📝 系统实时日志", padding=10) + log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) - # 注意:这里字体设为 Consolas 或其他等宽字体,看起来更像代码日志 self.log_text = scrolledtext.ScrolledText( log_frame, height=10, state="disabled", font=("Consolas", 9) ) self.log_text.pack(fill=tk.BOTH, expand=True) - # 【新增】启动日志轮询 - # 告诉 GUI:每隔 100ms 检查一下队列里有没有新日志 + # 启动日志轮询 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() - - # 写入 GUI self.log_text.config(state="normal") - self.log_text.insert(tk.END, msg) # msg 已经包含了换行符 - self.log_text.see(tk.END) # 自动滚动到底部 + self.log_text.insert(tk.END, msg) + self.log_text.see(tk.END) self.log_text.config(state="disabled") except queue.Empty: break - - # 递归调用:100ms 后再次执行自己 self.root.after(100, self.poll_log_queue) - # --- 辅助方法:线程包装器 --- + # --- 线程包装器 --- def run_in_thread(self, target_func, *args): - """在单独的线程中运行函数""" - def thread_task(): try: - # 这里不需要手动 self.log 了,因为 target_func 内部的 logger 会自动触发 sink target_func(*args) - logger.success("✅ 当前任务执行完毕。") # 使用 logger 输出成功 + logger.success("✅ 当前任务执行完毕。") except Exception as e: - logger.error(f"❌ 发生错误: {str(e)}") # 使用 logger 输出错误 + logger.error(f"❌ 发生错误: {str(e)}") import traceback - logger.error(traceback.format_exc()) threading.Thread(target=thread_task, daemon=True).start() # ========================================== - # 按钮事件 (不需要改动,逻辑都在 thread_task 里处理了) + # 按钮事件 (业务逻辑保持不变) # ========================================== def run_generate_folders(self): self.run_in_thread(generate_template) @@ -160,18 +170,36 @@ class ReportApp: self.run_in_thread(generate_report) def run_convert_pdf(self): - # 传入 output_folder 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): - 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): 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() @@ -182,16 +210,10 @@ class ReportApp: # 启动入口 # ========================================== def applicationUI(): - # 【核心配置】在启动 GUI 前,配置 loguru - # 1. remove() 移除默认的控制台输出(如果你想保留控制台黑窗口,可以注释掉这行) - # logger.remove() - - # 2. 添加我们的自定义 sink (queue_sink) - # format 可以自定义,这里保持简单,或者尽量模拟 loguru 默认格式 logger.add( queue_sink, format="{time:HH:mm:ss} | {level: <8} | {message}", - level="INFO", # 只显示 INFO 及以上级别,避免 DEBUG 信息刷屏 + level="INFO", ) root = tk.Tk() diff --git a/config/config.py b/config/config.py index 81825b1..d3f5817 100644 --- a/config/config.py +++ b/config/config.py @@ -11,14 +11,15 @@ except ImportError: print("错误: 缺少 TOML 解析库。请运行: pip install tomli") sys.exit(1) + def get_main_path(): """ 获取程序运行的根目录 - 兼容: + 兼容: 1. PyInstaller 打包后的 .exe 环境 2. 开发环境 (假设此脚本在子文件夹中,如 utils/) """ - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): # --- 情况 A: 打包后的 exe --- # exe 就在根目录下,直接取 exe 所在目录 return os.path.dirname(sys.executable) @@ -26,25 +27,26 @@ def get_main_path(): # --- 情况 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 + # ========================================== # 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) @@ -56,12 +58,14 @@ def load_config(config_filename="config.toml"): try: with open(config_path, "rb") as f: data = toml.load(f) - + # 将 TOML 的层级结构映射回扁平结构 # 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录) config = { - "root_path": main_dir, # 方便调试,把根目录也存进去 - "source_file": os.path.join(main_dir, data["paths"]["source_file"]), + "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"]), @@ -70,9 +74,9 @@ def load_config(config_filename="config.toml"): "teachers": data["class_info"]["teachers"], "default_comment": data["defaults"].get("default_comment", "暂无评语"), "age_group": data["defaults"].get("age_group", "大班上学期"), - "ai": data["ai"] + "ai": data["ai"], } return config except Exception as e: print(f"读取配置文件出错: {e}") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/data/names.xlsx b/data/names.xlsx index 458031d..f5e6904 100644 Binary files a/data/names.xlsx and b/data/names.xlsx differ diff --git a/main.py b/main.py index 26e64ec..94f3131 100644 --- a/main.py +++ b/main.py @@ -5,8 +5,7 @@ from utils.generate_utils import ( batch_convert_folder, generate_zodiac, ) -from utils.file_utils import export_data_folder, initialize_project -from UI import applicationUI +from utils.file_utils import export_templates_folder, initialize_project, export_data def application(): @@ -38,7 +37,8 @@ def application(): table.add_row("5.", "📑 生肖转化(根据生日)") table.add_row("6.", "📦 导出数据模板(Zip)") table.add_row("7.", "📦 初始化系统") - table.add_row("8.", "🚪 退出系统") + table.add_row("8.", "📤 导出数据") + table.add_row("9.", "🚪 退出系统") # 4. 将表格放入面板,并居中显示 panel = Panel( @@ -56,7 +56,7 @@ def application(): choice = Prompt.ask( "👉 请输入序号执行", - choices=["1", "2", "3", "4", "5", "6", "7","8"], + choices=["1", "2", "3", "4", "5", "6", "7","8","9"], default="1", ) @@ -88,12 +88,16 @@ def application(): elif choice == "6": console.rule("[bold magenta]正在执行: 导出数据模板[/]") # 调用上面的批量转换函数,传入你的 output 文件夹路径 - export_data_folder() + 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[/] 键返回主菜单...") diff --git a/pyproject.toml b/pyproject.toml index 75e25a0..67a4316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pandas>=2.3.3", "pandas-stubs==2.3.3.251201", "python-pptx>=1.0.2", + "pywin32>=311", "rich>=14.2.0", "tomli>=2.3.0", ] diff --git a/templates/names K4A.xlsx b/templates/names K4A.xlsx new file mode 100644 index 0000000..3eb3bb3 Binary files /dev/null and b/templates/names K4A.xlsx differ diff --git a/templates/大班幼儿学期发展报告.pptx b/templates/大班幼儿学期发展报告.pptx index b29386f..f10505b 100644 Binary files a/templates/大班幼儿学期发展报告.pptx and b/templates/大班幼儿学期发展报告.pptx differ diff --git a/utils/file_utils.py b/utils/file_utils.py index 2c0d2a2..68056f4 100644 --- a/utils/file_utils.py +++ b/utils/file_utils.py @@ -2,14 +2,16 @@ import shutil import os import time from loguru import logger +import zipfile -def export_data_folder(source_folder="data", output_folder="backup"): +def export_templates_folder(output_folder="backup"): """ 将指定文件夹压缩为 zip 包 :param source_folder: 要压缩的文件夹路径 (默认 'data') :param output_folder: 压缩包存放的文件夹路径 (默认 'backup') """ + source_folder = "data" try: # 1. 检查源文件夹是否存在 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()) +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="."): """ 初始化项目:清空 data,重建目录,复制模板 diff --git a/utils/generate_utils.py b/utils/generate_utils.py index 390cc30..6f3164b 100644 --- a/utils/generate_utils.py +++ b/utils/generate_utils.py @@ -1,5 +1,6 @@ import os import time +import pythoncom import pandas as pd 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.image_utils import find_image_path 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,这里定义一个 @@ -23,6 +30,7 @@ console = Console() # ========================================== config = load_config("config.toml") + # ========================================== # 1. 生成模板(根据names.xlsx文件生成名字图片文件夹) # ========================================== @@ -58,8 +66,10 @@ def generate_template(): logger.error(f"程序运行出错: {str(e)}") # 打印详细报错位置,方便调试 import traceback + logger.error(traceback.format_exc()) + # ========================================== # 2. 生成评语(根据names.xlsx文件生成评价) # ========================================== @@ -83,20 +93,24 @@ def generate_comment_all(): # 这样我们可以通过索引 i 精准地把数据写回某一行 for i in df.index: name = df.at[i, "姓名"] # 获取当前行的姓名 - sex = df.at[i,"性别"] + sex = df.at[i, "性别"] if pd.isna(sex): sex = "男" else: sex = str(sex).strip() - + # 健壮性处理 - if pd.isna(name): continue # 跳过空行 + if pd.isna(name): + continue # 跳过空行 name = str(name).strip() # 获取当前行的特征(如果Excel里有“特征”这一列就读,没有就用默认值) # 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..." - traits = df.at[i, "表现特征"] if "表现特征" in df.columns and not pd.isna( - df.at[i, "表现特征"]) else "有礼貌、守纪律" + traits = ( + df.at[i, "表现特征"] + if "表现特征" in df.columns and not pd.isna(df.at[i, "表现特征"]) + else "有礼貌、守纪律" + ) # 优化:如果“评价”列已经有内容了,跳过不生成(节省API费用) current_comment = df.at[i, "评价"] @@ -109,7 +123,9 @@ def generate_comment_all(): try: # 调用你的生成函数,并【接收返回值】 # 注意:这里假设 generate_comment 返回的是清洗后的字符串 - generated_text = generate_comment(name, config["age_group"], traits,sex) + generated_text = generate_comment( + name, config["age_group"], traits, sex + ) # --- 将结果写入 DataFrame --- df.at[i, "评价"] = generated_text @@ -136,8 +152,10 @@ def generate_comment_all(): except Exception as e: logger.error(f"程序运行出错: {str(e)}") import traceback + logger.error(traceback.format_exc()) + # ========================================== # 3. 生成成长报告(根据names.xlsx文件生成) # ========================================== @@ -148,10 +166,11 @@ def generate_report(): time.sleep(2) os.makedirs(config["output_folder"], exist_ok=True) - + # 检查模版文件是否存在 if not os.path.exists(config["source_file"]): - logger.info(f"错误: 找不到模版文件 {config['source_file']}") + logger.info(f"错误: 找不到模版文件 {config["source_file"]}") return + # 检查数据文件是否存在 if not os.path.exists(config["excel_file"]): logger.info(f"错误: 找不到数据文件 {config['excel_file']}") return @@ -160,8 +179,18 @@ def generate_report(): # 2. 读取数据 df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") # 确保列名对应 - columns = ["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物", - "评价"] + columns = [ + "姓名", + "英文名", + "性别", + "生日", + "属相", + "我的好朋友", + "我的爱好", + "喜欢的游戏", + "喜欢吃的食物", + "评价", + ] datas = df[columns].values.tolist() teacher_names_str = " ".join(config["teachers"]) @@ -171,13 +200,23 @@ def generate_report(): # 3. 循环处理 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}") # 每次循环重新加载模版 - template_source_file = os.path.join("templates", config["source_file"]) - prs = Presentation(template_source_file) + prs = Presentation(config["source_file"]) # --- 页面 1 --- replace_one_page(prs, name, config["class_name"]) @@ -187,32 +226,47 @@ def generate_report(): # --- 页面 3 --- 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): - me_image_path = find_image_path(student_image_folder, "me_image") - + me_image_path = find_image_path(student_image_folder, "me") + # 构造信息字典供 helper 使用 info_dict = { - "name": name, "english_name": english_name, "sex": sex, - "birthday": birthday, "zodiac": zodiac, "friend": friend, - "hobby": hobby, "game": game, "food": food + "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, } # 逻辑:必须同时满足 "不是None" 且 "是字符串" 且 "文件存在" 才能执行 - if me_image_path and isinstance(me_image_path, str) and os.path.exists(me_image_path): - logger.info(f"学生:{name},图片路径有效: {me_image_path},正在替换...") + 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) else: # 只有在这里打印日志,告诉用户跳过了,但不中断程序 - replace_three_page(prs, info_dict, None) + replace_three_page(prs, info_dict, None) else: - logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") + logger.warning(f"⚠️ 警告: 学生:{name},学生图片文件夹不存在 {student_image_folder}") # --- 页面 4 --- - class_image_path = find_image_path(config["image_folder"], config["class_name"]) - if os.path.exists(class_image_path): + 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) + ): replace_four_page(prs, class_image_path) else: - logger.warning(f"错误: 班级图片文件不存在 {class_image_path}") + logger.warning(f"⚠️ 警告: 班级图片文件不存在 {class_image_path}") # --- 页面 5 --- if os.path.exists(student_image_folder): @@ -228,7 +282,9 @@ def generate_report(): replace_five_page(prs, img1_path, img1_path) # 情况C: 一张都没找到 else: - logger.warning(f"⚠️ 警告: {name} 缺少作品照片 (1.jpg/png 或 2.jpg/png)[/]") + logger.warning( + f"⚠️ 警告: {name} 缺少作品照片 (1.jpg/png 或 2.jpg/png)[/]" + ) else: logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") @@ -242,7 +298,9 @@ def generate_report(): prs.save(output_path) logger.success(f"学生:{name},保存成功: {new_filename}") except PermissionError: - logger.error(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。") + logger.error( + f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。" + ) logger.success("所有报告生成完毕!") @@ -250,63 +308,82 @@ def generate_report(): logger.error(f"程序运行出错: {str(e)}") # 打印详细报错位置,方便调试 import traceback + logger.error(traceback.format_exc()) + # ========================================== # 5. 转换格式(根据names.xlsx文件生成PPT转PDF) # ========================================== def batch_convert_folder(folder_path): """ 【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint,速度快) + 已修复多线程 CoInitialize 报错,并适配 GUI 日志 """ - folder_path = os.path.abspath(folder_path) - if not os.path.exists(folder_path): - print("文件夹不存在") - return + # 【核心修复 1】子线程初始化 COM 组件 + pythoncom.CoInitialize() - # 获取所有 ppt/pptx 文件 - files = [f for f in os.listdir(folder_path) if f.lower().endswith(('.ppt', '.pptx'))] - - if not files: - print("没有找到 PPT 文件") - return - - print(f"发现 {len(files)} 个文件,准备开始转换...") - - powerpoint = None try: - # 1. 启动应用 (只启动一次) - powerpoint = comtypes.client.CreateObject("PowerPoint.Application") - # 某些环境下需要设为可见,否则无法运行 - # powerpoint.Visible = 1 + folder_path = os.path.abspath(folder_path) + if not os.path.exists(folder_path): + logger.error(f"文件夹不存在: {folder_path}") + return - for filename in files: - ppt_path = os.path.join(folder_path, filename) - pdf_path = os.path.splitext(ppt_path)[0] + ".pdf" + # 获取所有 ppt/pptx 文件 + files = [ + f for f in os.listdir(folder_path) if f.lower().endswith((".ppt", ".pptx")) + ] - # 如果 PDF 已存在,可以选择跳过 - if os.path.exists(pdf_path): - print(f"[跳过] 已存在: {filename}") - continue + if not files: + logger.warning("没有找到 PPT 文件") + return - print(f"正在转换: {filename} ...") + logger.info(f"发现 {len(files)} 个文件,准备开始转换...") - try: - # 打开 -> 另存为 -> 关闭 - deck = powerpoint.Presentations.Open(ppt_path) - deck.SaveAs(pdf_path, 32) - deck.Close() - except Exception as e: - print(f"文件 {filename} 转换出错: {e}") + powerpoint = None + try: + # 1. 启动应用 (只启动一次) + powerpoint = comtypes.client.CreateObject("PowerPoint.Application") + + # 【建议】在后台线程运行时,有时设置为不可见更稳定, + # 但如果遇到转换卡死,可以尝试去掉下面这行的注释,让它显示出来 + # powerpoint.Visible = 1 + + for filename in files: + ppt_path = os.path.join(folder_path, filename) + pdf_path = os.path.splitext(ppt_path)[0] + ".pdf" + + # 如果 PDF 已存在,可以选择跳过 + if os.path.exists(pdf_path): + logger.info(f"[跳过] 已存在: {filename}") + continue + + logger.info(f"正在转换: {filename} ...") + + try: + # 打开 -> 另存为 -> 关闭 + deck = powerpoint.Presentations.Open(ppt_path) + deck.SaveAs(pdf_path, 32) # 32 代表 PDF 格式 + deck.Close() + except Exception as e: + logger.error(f"文件 {filename} 转换出错: {e}") + + except Exception as e: + logger.error(f"PowerPoint 进程启动出错: {e}") + finally: + # 2. 退出应用 + if powerpoint: + try: + powerpoint.Quit() + except: + pass + logger.success("PowerPoint 已关闭,批量转换完成。") except Exception as e: - print(f"PowerPoint 进程出错: {e}") + logger.error(f"未知错误: {e}") finally: - # 2. 退出应用 - if powerpoint: - powerpoint.Quit() - print("PowerPoint 已关闭,批量转换完成。") - + # 【核心修复 2】释放资源 + pythoncom.CoUninitialize() # ========================================== # 5. 生成属相(根据names.xlsx文件生成属相) @@ -319,8 +396,8 @@ def generate_zodiac(): df = pd.read_excel(excel_path, sheet_name="Sheet1") # 2. 检查必要的列 - date_column = '生日' - target_column = '属相' + date_column = "生日" + target_column = "属相" if date_column not in df.columns: logger.error(f"Excel中找不到列名:【{date_column}】,请检查表头。") @@ -335,12 +412,12 @@ def generate_zodiac(): logger.info(f"开始生成学生属相,共 {total_count} 位学生...") # 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) - + # 5. 保存结果 - save_path = excel_path - + save_path = excel_path + try: df.to_excel(save_path, index=False) logger.success(f"所有属相已更新并写入文件:{save_path}") @@ -353,6 +430,5 @@ def generate_zodiac(): except Exception as e: logger.error(f"程序运行出错: {str(e)}") import traceback + logger.error(traceback.format_exc()) - - diff --git a/uv.lock b/uv.lock index 9d9e5d1..7a4d3e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -122,6 +122,7 @@ dependencies = [ { name = "pandas" }, { name = "pandas-stubs" }, { name = "python-pptx" }, + { name = "pywin32" }, { name = "rich" }, { name = "tomli" }, ] @@ -136,6 +137,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.3" }, { name = "pandas-stubs", specifier = "==2.3.3.251201" }, { name = "python-pptx", specifier = ">=1.0.2" }, + { name = "pywin32", specifier = ">=311" }, { name = "rich", specifier = ">=14.2.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" }, ] +[[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]] name = "pyyaml" version = "6.0.3"