diff --git a/UI.py b/UI.py new file mode 100644 index 0000000..a793e10 --- /dev/null +++ b/UI.py @@ -0,0 +1,199 @@ +import tkinter as tk +from tkinter import ttk +from tkinter import scrolledtext +from tkinter import messagebox +import threading +import sys +import time +import queue # 【新增】引入队列库 +import re # 【新增】用于去除 ANSI 颜色代码 + +from loguru import logger # 【新增】需要导入 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_data_folder, initialize_project + +# ========================================== +# 0. 全局配置与队列准备 +# ========================================== +config = load_config("config.toml") + +# 【新增】创建一个全局队列,用于存放日志消息 +log_queue = queue.Queue() + + +def ansi_cleaner(text): + """【辅助函数】去除 loguru 输出中的 ANSI 颜色代码 (例如 \x1b[32m)""" + 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 里会有乱码 + 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("700x600") # 稍微调大一点 + + # 设置样式 + 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") + + # --- 1. 标题区域 --- + header_frame = ttk.Frame(root, padding="10 20 10 10") + 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. 按钮功能区域 --- + btn_frame = ttk.Frame(root, padding=10) + btn_frame.pack(fill=tk.X) + + buttons = [ + ("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), + ] + + 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") + + btn_frame.columnconfigure(0, weight=1) + btn_frame.columnconfigure(1, weight=1) + + # --- 3. 日志输出区域 --- + log_frame = ttk.LabelFrame(root, text="系统实时日志", padding=10) + log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=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 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.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 输出成功 + except Exception as e: + logger.error(f"❌ 发生错误: {str(e)}") # 使用 logger 输出错误 + 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) + + 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): + # 传入 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) + + def run_initialize_project(self): + self.run_in_thread(initialize_project) + + def quit_app(self): + if messagebox.askokcancel("退出", "确定要退出系统吗?"): + self.root.destroy() + sys.exit() + + +# ========================================== +# 启动入口 +# ========================================== +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 信息刷屏 + ) + + root = tk.Tk() + app = ReportApp(root) + root.mainloop() diff --git a/config/config.py b/config/config.py index d514389..81825b1 100644 --- a/config/config.py +++ b/config/config.py @@ -1,37 +1,71 @@ -# 尝试导入 toml 解析库 (兼容 Python 3.11+ 和旧版本) import os import sys +# 尝试导入 toml 解析库 try: - import tomllib as toml # Python 3.11+ 内置 + import tomllib as toml # Python 3.11+ except ImportError: try: - import tomli as toml # 需要 pip install tomli + import tomli as toml # pip install tomli except ImportError: print("错误: 缺少 TOML 解析库。请运行: pip install tomli") sys.exit(1) +def get_main_path(): + """ + 获取程序运行的根目录 + 兼容: + 1. PyInstaller 打包后的 .exe 环境 + 2. 开发环境 (假设此脚本在子文件夹中,如 utils/) + """ + if getattr(sys, 'frozen', False): + # --- 情况 A: 打包后的 exe --- + # exe 就在根目录下,直接取 exe 所在目录 + 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 # ========================================== # 1. 配置加载 (Config Loader) # ========================================== -def load_config(config_path="config.toml"): - """读取 TOML 配置文件并转换为扁平字典""" +def load_config(config_filename="config.toml"): + """读取 TOML 配置文件""" + + # 1. 先获取正确的根目录 + main_dir = get_main_path() + + # 2. 拼接配置文件的绝对路径 (防止在不同目录下运行脚本时找不到配置文件) + config_path = os.path.join(main_dir, config_filename) + if not os.path.exists(config_path): - print(f"错误: 找不到配置文件 {config_path}") + print(f"错误: 在路径 {main_dir} 下找不到配置文件 {config_filename}") + print(f"尝试寻找的完整路径是: {config_path}") sys.exit(1) try: with open(config_path, "rb") as f: data = toml.load(f) - - # 将 TOML 的层级结构映射回原本的扁平结构,保持代码其余部分不用大改 + + # 将 TOML 的层级结构映射回扁平结构 + # 关键点:所有的 os.path.join 都必须基于 main_dir (项目根目录) config = { - "source_file": data["paths"]["source_file"], - "output_folder": data["paths"]["output_folder"], - "excel_file": data["paths"]["excel_file"], - "image_folder": data["paths"]["image_folder"], - "fonts_dir": data["paths"]["fonts_dir"], + "root_path": main_dir, # 方便调试,把根目录也存进去 + "source_file": os.path.join(main_dir, 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", "暂无评语"), @@ -41,4 +75,4 @@ def load_config(config_path="config.toml"): return config except Exception as e: print(f"读取配置文件出错: {e}") - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/data/images/K4D.jpg b/data/images/K4D.jpg deleted file mode 100644 index aa5806c..0000000 Binary files a/data/images/K4D.jpg and /dev/null differ diff --git a/data/images/测试/1.jpg b/data/images/测试/1.jpg deleted file mode 100644 index aa5806c..0000000 Binary files a/data/images/测试/1.jpg and /dev/null differ diff --git a/data/images/测试/2.jpg b/data/images/测试/2.jpg deleted file mode 100644 index aa5806c..0000000 Binary files a/data/images/测试/2.jpg and /dev/null differ diff --git a/data/images/测试/me_image.jpg b/data/images/测试/me_image.jpg deleted file mode 100644 index aa5806c..0000000 Binary files a/data/images/测试/me_image.jpg and /dev/null differ diff --git a/data/names.xlsx b/data/names.xlsx index eb8bc52..458031d 100644 Binary files a/data/names.xlsx and b/data/names.xlsx differ diff --git a/main.py b/main.py index 2369856..e4fe42a 100644 --- a/main.py +++ b/main.py @@ -1,285 +1,12 @@ -import os -import time - -import pandas as pd -from loguru import logger -from pptx import Presentation -from pptx.util import Pt -from rich import box -from rich.align import Align -from rich.console import Console -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn -from rich.prompt import Prompt -from rich.table import Table - -from config.config import load_config -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.pef_utils import batch_convert_folder -from utils.pptx_utils import replace_text_in_slide, replace_picture - -# 如果你之前没有全局定义 console,这里定义一个 -console = Console() - -# ========================================== -# 1. 配置区域 (Configuration) -# ========================================== -config = load_config("config.toml") - - -# ========================================== -# 2. 业务逻辑函数 (Business Logic) -# ========================================== -def replace_one_page(prs, name, class_name): - """替换第一页信息""" - replace_text_in_slide(prs, 0, "name", name) - replace_text_in_slide(prs, 0, "class", class_name) - - -def replace_two_page(prs, comments, teacher_name): - """替换第二页信息""" - replace_text_in_slide(prs, 1, "comments", comments) - replace_text_in_slide(prs, 1, "teacher_name", teacher_name) - - -def replace_three_page(prs, info_dict, me_image): - """替换第三页信息""" - # 使用字典解包传递多个字段,减少参数数量 - fields = ["name", "english_name", "sex", "birthday", "zodiac", "friend", "hobby", "game", "food"] - for field in fields: - replace_text_in_slide(prs, 2, field, info_dict.get(field, "")) - replace_picture(prs, 2, "me_image", me_image) - - -def replace_four_page(prs, class_image): - """替换第四页信息""" - replace_picture(prs, 3, "class_image", class_image) - - -def replace_five_page(prs, image1, image2): - """替换第五页信息""" - replace_picture(prs, 4, "image1", image1) - replace_picture(prs, 4, "image2", image2) - - -# ========================================== -# 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) - - if not os.path.exists(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 - - try: - # 2. 读取数据 - df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") - # 确保列名对应 - columns = ["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物", - "评价"] - datas = df[columns].values.tolist() - - teacher_names_str = " ".join(config["teachers"]) - - logger.info(f"开始处理,共 {len(datas)} 位学生...") - - # 3. 循环处理 - for i, row_data in enumerate(datas): - # 解包数据 - (name, english_name, sex, birthday, zodiac, friend, hobby, game, food, comments) = row_data - - logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}") - - # 每次循环重新加载模版 - prs = Presentation(config["source_file"]) - - # --- 页面 1 --- - replace_one_page(prs, name, config["class_name"]) - - # --- 页面 2 --- - replace_two_page(prs, comments, teacher_names_str) - - # --- 页面 3 --- - student_image_folder = os.path.join(config["image_folder"], name) - if os.path.exists(student_image_folder): - me_image_path = find_image_path(student_image_folder, "me_image") - # 构造信息字典供 helper 使用 - info_dict = { - "name": name, "english_name": english_name, "sex": sex, - "birthday": birthday, "zodiac": zodiac, "friend": friend, - "hobby": hobby, "game": game, "food": food - } - replace_three_page(prs, info_dict, me_image_path) - else: - logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") - - # --- 页面 4 --- - class_image_path = find_image_path(config["image_folder"], config["class_name"]) - if os.path.exists(class_image_path): - replace_four_page(prs, class_image_path) - else: - logger.warning(f"错误: 班级图片文件不存在 {class_image_path}") - - # --- 页面 5 --- - if os.path.exists(student_image_folder): - img1_path = find_image_path(student_image_folder, "1") - img2_path = find_image_path(student_image_folder, "2") - # 逻辑优化: - # 情况A: 两张都找到了 -> 正常插入 - if img1_path and img2_path: - replace_five_page(prs, img1_path, img2_path) - - # 情况B: 只找到了 1 -> 两张图都用 1 (避免报错) - elif img1_path and not img2_path: - replace_five_page(prs, img1_path, img1_path) - # 情况C: 一张都没找到 - else: - logger.warning(f"⚠️ 警告: {name} 缺少生活照片 (1.jpg/png 或 2.jpg/png)[/]") - else: - logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") - - # --- 保存文件 --- - file_ext = os.path.splitext(config["source_file"])[1] - safe_name = str(name).strip() - new_filename = f"{config['class_name']} {safe_name} 幼儿成长报告{file_ext}" - output_path = os.path.join(config["output_folder"], new_filename) - - try: - prs.save(output_path) - logger.success(f"学生:{name},保存成功: {new_filename}") - except PermissionError: - logger.error(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。") - - logger.success("所有报告生成完毕!") - - except Exception as e: - logger.error(f"程序运行出错: {str(e)}") - # 打印详细报错位置,方便调试 - import traceback - logger.error(traceback.format_exc()) - - -# ========================================== -# 4. 生成模板(根据names.xlsx文件生成名字图片文件夹) -# ========================================== -def generate_template(): - try: - # 2. 读取数据 - df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") - - # --- 修改点开始 --- - # 直接读取 "姓名" 这一列,不使用列表包裹列名,这样得到的是一维数据 - datas = df["姓名"].values.tolist() - # --- 修改点结束 --- - - logger.info(f"开始生成学生模版文件,共 {len(datas)} 位学生...") - - # 3. 循环处理 - # 此时 name 就是字符串 '张三',而不是列表 ['张三'] - for i, name in enumerate(datas): - 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): - logger.info(f"学生图片文件夹已存在 {student_folder}") - else: - logger.info(f"正在生成学生图片文件夹 {student_folder}") - os.makedirs(student_folder, exist_ok=True) - - except Exception as e: - logger.error(f"程序运行出错: {str(e)}") - # 打印详细报错位置,方便调试 - import traceback - logger.error(traceback.format_exc()) - - -# ========================================== -# 5. 生成评语(根据names.xlsx文件生成评价) -# ========================================== -def generate_comment_all(): - try: - # 1. 读取数据 - excel_path = config["excel_file"] - df = pd.read_excel(excel_path, sheet_name="Sheet1") - - # 检查是否存在"评价"列,不存在则新建(防止报错) - if "评价" not in df.columns: - df["评价"] = "" - - # --- 获取总行数,用于日志 --- - # 强制将“评价”列转换为 object 类型 - total_count = len(df) - logger.info(f"开始生成学生评语,共 {total_count} 位学生...") - - df["评价"] = df["评价"].astype("object") - # --- 遍历 DataFrame 的索引 (index) --- - # 这样我们可以通过索引 i 精准地把数据写回某一行 - for i in df.index: - name = df.at[i, "姓名"] # 获取当前行的姓名 - - # 健壮性处理 - 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 "有礼貌、守纪律" - - # 优化:如果“评价”列已经有内容了,跳过不生成(节省API费用) - current_comment = df.at[i, "评价"] - if not pd.isna(current_comment) and str(current_comment).strip() != "": - logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。") - continue - - logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}") - - try: - # 调用你的生成函数,并【接收返回值】 - # 注意:这里假设 generate_comment 返回的是清洗后的字符串 - generated_text = generate_comment(name, config["age_group"], traits) - - # --- 将结果写入 DataFrame --- - df.at[i, "评价"] = generated_text - - logger.success(f"学生:{name},评语生成完毕") - - # 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失 - if (i + 1) % 5 == 0: - df.to_excel(excel_path, index=False) - logger.info("--- 阶段性保存成功 ---") - - time.sleep(1) # 避免触发API速率限制 - - 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}") - - 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()) +from utils.generate_utils import ( + generate_template, + generate_comment_all, + generate_report, + batch_convert_folder, + generate_zodiac, +) +from utils.file_utils import export_data_folder, initialize_project +from UI import applicationUI def application(): @@ -304,11 +31,14 @@ def application(): table.add_column(justify="left") # 3. 添加行内容 - table.add_row("1.", "📁 生成模板") - table.add_row("2.", "🤖 生成评语") - table.add_row("3.", "📊 生成报告") - table.add_row("4.", "📑 格式转换") # 新增 - table.add_row("5.", "🚪 退出系统") + 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.", "🚪 退出系统") # 4. 将表格放入面板,并居中显示 panel = Panel( @@ -317,19 +47,25 @@ def application(): subtitle="[dim]By 寒寒", width=60, border_style="bright_blue", - box=box.ROUNDED # 圆角边框更柔和 + box=box.ROUNDED, # 圆角边框更柔和 ) # 使用 Align.center 让整个菜单在屏幕中间显示 console.print(Align.center(panel, vertical="middle")) console.print("\n") # 留点空隙 - choice = Prompt.ask("👉 请输入序号执行", choices=["1", "2", "3", "4", "5"], default="1") + choice = Prompt.ask( + "👉 请输入序号执行", + choices=["1", "2", "3", "4", "5", "6", "7","8"], + default="1", + ) try: if choice == "1": console.rule("[bold cyan]正在执行: 生成模板[/]") - with console.status("[bold green]正在创建文件夹结构...[/]", spinner="dots"): + with console.status( + "[bold green]正在创建文件夹结构...[/]", spinner="dots" + ): generate_template() elif choice == "2": console.rule("[bold yellow]正在执行: AI 生成评语[/]") @@ -337,20 +73,38 @@ def application(): generate_comment_all() elif choice == "3": console.rule("[bold blue]正在执行: PPT 合成[/]") - with console.status("[bold blue]正在处理图片和文字...[/]", spinner="earth"): + 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_data_folder() + elif choice == "7": + console.rule("[bold magenta]正在执行: 初始化系统[/]") + # 调用上面的批量转换函数,传入你的 output 文件夹路径 + initialize_project() + elif choice == "8": 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")) + console.print( + Panel( + f"[bold red]❌ 发生错误:[/]\n{e}", title="Error", border_style="red" + ) + ) Prompt.ask("按 Enter 键继续...") if __name__ == "__main__": - application() + applicationUI() diff --git a/start_app.bat b/start_app.bat new file mode 100644 index 0000000..a8c402d --- /dev/null +++ b/start_app.bat @@ -0,0 +1,42 @@ +@echo off +:: ------------------------------------------------ +:: 自动切换编码为 UTF-8,解决中文乱码问题 +chcp 65001 >nul +:: ------------------------------------------------ + +title 幼儿园成长报告助手 +cd /d "%~dp0" + +echo. +echo ========================================== +echo 正在启动 幼儿园成长报告助手 +echo ========================================== +echo. + +:: 检查 uv 是否安装 +uv --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] 未检测到 uv 工具! + echo 请先运行: pip install uv + pause + exit /b +) + +echo [INFO] 环境检查通过,正在运行主程序... +echo --------------------------------------------------- + +:: 这里的 gui_app.py 就是你刚才保存的那个带界面的 Python 文件名 +:: 如果你的文件名不一样,请修改下面这一行 +uv run main.py + +:: 错误捕获 +if %errorlevel% neq 0 ( + echo. + echo --------------------------------------------------- + echo [ERROR] 程序异常退出 (代码: %errorlevel%) + echo 请检查上方报错信息。 + pause +) else ( + echo. + echo [INFO] 程序已正常结束。 +) \ No newline at end of file diff --git a/templates/names.xlsx b/templates/names.xlsx new file mode 100644 index 0000000..6ca2492 Binary files /dev/null and b/templates/names.xlsx differ diff --git a/utils/agent_utils.py b/utils/agent_utils.py index cd564a8..0bc08ff 100644 --- a/utils/agent_utils.py +++ b/utils/agent_utils.py @@ -10,12 +10,13 @@ from config.config import load_config config = load_config("config.toml") -def generate_comment(name, age_group, traits): +def generate_comment(name, age_group, traits,sex): """ 生成评语 :param name: 学生姓名 :param age_group: 所在班级 :param traits: 表现特征 + :param sex: 性别 :return: 评语 """ @@ -29,7 +30,7 @@ def generate_comment(name, age_group, traits): # 2. 构建 Prompt Template prompt = ChatPromptTemplate.from_messages([ ("system", ai_config["prompt"]), - ("human", "学生姓名:{name}\n所在班级:{age_group}\n表现特征:{traits}\n\n请开始撰写评语:") + ("human", "学生姓名:{name}\n所在班级:{age_group}\n性别:{sex}\n表现特征:{traits}\n\n请开始撰写评语:") ]) # 3. 组装链 (Prompt -> Model -> OutputParser) @@ -40,11 +41,12 @@ def generate_comment(name, age_group, traits): comment = chain.invoke({ "name": name, "age_group": age_group, - "traits": traits + "traits": traits, + "sex": sex }) cleaned_text = re.sub(r'\s+', '', comment) logger.success(f"学生:{name} =>生成评语成功: {cleaned_text}") return cleaned_text except Exception as e: - print(f"生成评语失败: {e}") + logger.error(f"生成评语失败: {e}") return "生成失败,请检查网络或Key。" diff --git a/utils/file_utils.py b/utils/file_utils.py new file mode 100644 index 0000000..2c0d2a2 --- /dev/null +++ b/utils/file_utils.py @@ -0,0 +1,121 @@ +import shutil +import os +import time +from loguru import logger + + +def export_data_folder(source_folder="data", output_folder="backup"): + """ + 将指定文件夹压缩为 zip 包 + :param source_folder: 要压缩的文件夹路径 (默认 'data') + :param output_folder: 压缩包存放的文件夹路径 (默认 'backup') + """ + try: + # 1. 检查源文件夹是否存在 + if not os.path.exists(source_folder): + logger.error(f"备份失败: 找不到源文件夹 '{source_folder}'") + return + + # 2. 确保输出目录存在 + if not os.path.exists(output_folder): + os.makedirs(output_folder) + logger.info(f"创建备份目录: {output_folder}") + + # 3. 生成带时间戳的文件名 (例如: data_backup_20251211_103000) + timestamp = time.strftime("%Y%m%d_%H%M%S") + # 注意: make_archive 不需要写后缀 .zip,它会自动加 + base_name = os.path.join(output_folder, f"data_备份_{timestamp}") + + logger.info(f"正在压缩 '{source_folder}' ...") + + # 4. 执行压缩 + # format="zip": 压缩格式 + # root_dir: 要压缩的根目录的上级目录 (通常填 None) + # base_dir: 要压缩的具体目录 + zip_path = shutil.make_archive( + base_name=base_name, + format="zip", + root_dir=os.path.dirname( + os.path.abspath(source_folder) + ), # 这里为了安全,定位到父级 + base_dir=os.path.basename( + os.path.abspath(source_folder) + ), # 只压缩 data 文件夹本身 + ) + + logger.success(f"✅ 备份成功! 文件已保存至:\n{zip_path}") + return zip_path + + except Exception as e: + logger.error(f"❌ 备份出错: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + + +def initialize_project(root_dir="."): + """ + 初始化项目:清空 data,重建目录,复制模板 + :param root_dir: 项目根目录 + """ + # 定义路径 + data_dir = os.path.join(root_dir, "data") + images_dir = os.path.join(data_dir, "images") + output_dir = os.path.join(root_dir, "output") + + # 模板源文件路径 + template_dir = os.path.join(root_dir, "templates") + src_excel = os.path.join(template_dir, "names.xlsx") + dst_excel = os.path.join(data_dir, "names.xlsx") + + logger.info("开始初始化/重置项目...") + + # --- 1. 清空 data 文件夹 --- + if os.path.exists(data_dir): + try: + # 暴力删除整个 data 文件夹及其内容 + shutil.rmtree(data_dir) + logger.info(f"已清理旧数据: {data_dir}") + except PermissionError: + logger.error( + f"❌ 删除失败!请检查 '{data_dir}' 下的文件(如 Excel)是否正在被打开。" + ) + return + except Exception as e: + logger.error(f"❌ 删除出错: {e}") + return + # --- 2. 清空 output_dir 文件夹 --- + if os.path.exists(output_dir): + try: + # 暴力删除整个 output 文件夹及其内容 + shutil.rmtree(output_dir) + logger.info(f"已清理旧数据: {output_dir}") + except PermissionError: + logger.error(f"❌ 删除失败!请检查 '{output_dir}' 下的文件是否正在被打开。") + return + except Exception as e: + logger.error(f"❌ 删除出错: {e}") + return + + # --- 2. 新建目录结构 --- + try: + # makedirs 会自动创建父级目录 (data 和 data/images 都会被创建) + os.makedirs(images_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + logger.info(f"✅ 已创建目录: {images_dir}") + except Exception as e: + logger.error(f"创建目录失败: {e}") + return + + # --- 3. 复制模板文件 --- + if os.path.exists(src_excel): + try: + shutil.copy(src_excel, dst_excel) + logger.success(f"✅ 成功从模板恢复: {dst_excel}") + logger.success("🎉 初始化完成!一切已恢复出厂设置。") + except Exception as e: + logger.error(f"复制模板文件失败: {e}") + else: + logger.warning( + f"⚠️ 警告: 模板文件不存在 ({src_excel}),data 文件夹内将没有 Excel 文件。" + ) diff --git a/utils/generate_utils.py b/utils/generate_utils.py new file mode 100644 index 0000000..390cc30 --- /dev/null +++ b/utils/generate_utils.py @@ -0,0 +1,358 @@ +import os +import time + +import pandas as pd +from loguru import logger +from pptx import Presentation +from rich.console import Console + +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.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 + + +# 如果你之前没有全局定义 console,这里定义一个 +console = Console() + +# ========================================== +# 1. 配置区域 (Configuration) +# ========================================== +config = load_config("config.toml") + +# ========================================== +# 1. 生成模板(根据names.xlsx文件生成名字图片文件夹) +# ========================================== +def generate_template(): + try: + # 2. 读取数据 + df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") + + # --- 修改点开始 --- + # 直接读取 "姓名" 这一列,不使用列表包裹列名,这样得到的是一维数据 + datas = df["姓名"].values.tolist() + # --- 修改点结束 --- + + logger.info(f"开始生成学生模版文件,共 {len(datas)} 位学生...") + + # 3. 循环处理 + # 此时 name 就是字符串 '张三',而不是列表 ['张三'] + for i, name in enumerate(datas): + 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): + logger.info(f"学生图片文件夹已存在 {student_folder}") + else: + logger.info(f"正在生成学生图片文件夹 {student_folder}") + os.makedirs(student_folder, exist_ok=True) + + except Exception as e: + logger.error(f"程序运行出错: {str(e)}") + # 打印详细报错位置,方便调试 + import traceback + logger.error(traceback.format_exc()) + +# ========================================== +# 2. 生成评语(根据names.xlsx文件生成评价) +# ========================================== +def generate_comment_all(): + try: + # 1. 读取数据 + excel_path = config["excel_file"] + df = pd.read_excel(excel_path, sheet_name="Sheet1") + + # 检查是否存在"评价"列,不存在则新建(防止报错) + if "评价" not in df.columns: + df["评价"] = "" + + # --- 获取总行数,用于日志 --- + # 强制将“评价”列转换为 object 类型 + total_count = len(df) + logger.info(f"开始生成学生评语,共 {total_count} 位学生...") + + 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 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 "有礼貌、守纪律" + + # 优化:如果“评价”列已经有内容了,跳过不生成(节省API费用) + current_comment = df.at[i, "评价"] + if not pd.isna(current_comment) and str(current_comment).strip() != "": + logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。") + continue + + logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}") + + try: + # 调用你的生成函数,并【接收返回值】 + # 注意:这里假设 generate_comment 返回的是清洗后的字符串 + generated_text = generate_comment(name, config["age_group"], traits,sex) + + # --- 将结果写入 DataFrame --- + df.at[i, "评价"] = generated_text + + logger.success(f"学生:{name},评语生成完毕") + + # 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失 + if (i + 1) % 5 == 0: + df.to_excel(excel_path, index=False) + logger.info("--- 阶段性保存成功 ---") + + time.sleep(1) # 避免触发API速率限制 + + 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}") + + 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) + + if not os.path.exists(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 + + try: + # 2. 读取数据 + df = pd.read_excel(config["excel_file"], sheet_name="Sheet1") + # 确保列名对应 + columns = ["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物", + "评价"] + datas = df[columns].values.tolist() + + teacher_names_str = " ".join(config["teachers"]) + + logger.info(f"开始处理,共 {len(datas)} 位学生...") + + # 3. 循环处理 + for i, row_data in enumerate(datas): + # 解包数据 + (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) + + # --- 页面 1 --- + replace_one_page(prs, name, config["class_name"]) + + # --- 页面 2 --- + replace_two_page(prs, comments, teacher_names_str) + + # --- 页面 3 --- + student_image_folder = os.path.join(config["image_folder"], name) + logger.info(f"学生图片文件夹: {student_image_folder}") + if os.path.exists(student_image_folder): + me_image_path = find_image_path(student_image_folder, "me_image") + + # 构造信息字典供 helper 使用 + info_dict = { + "name": name, "english_name": english_name, "sex": sex, + "birthday": birthday, "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},正在替换...") + replace_three_page(prs, info_dict, me_image_path) + else: + # 只有在这里打印日志,告诉用户跳过了,但不中断程序 + replace_three_page(prs, info_dict, None) + else: + logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") + + # --- 页面 4 --- + class_image_path = find_image_path(config["image_folder"], config["class_name"]) + if os.path.exists(class_image_path): + replace_four_page(prs, class_image_path) + else: + logger.warning(f"错误: 班级图片文件不存在 {class_image_path}") + + # --- 页面 5 --- + if os.path.exists(student_image_folder): + img1_path = find_image_path(student_image_folder, "1") + img2_path = find_image_path(student_image_folder, "2") + # 逻辑优化: + # 情况A: 两张都找到了 -> 正常插入 + if img1_path and img2_path: + replace_five_page(prs, img1_path, img2_path) + + # 情况B: 只找到了 1 -> 两张图都用 1 (避免报错) + elif img1_path and not img2_path: + replace_five_page(prs, img1_path, img1_path) + # 情况C: 一张都没找到 + else: + logger.warning(f"⚠️ 警告: {name} 缺少作品照片 (1.jpg/png 或 2.jpg/png)[/]") + else: + logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}") + + # --- 保存文件 --- + file_ext = os.path.splitext(config["source_file"])[1] + safe_name = str(name).strip() + new_filename = f"{config['class_name']} {safe_name} 幼儿成长报告{file_ext}" + output_path = os.path.join(config["output_folder"], new_filename) + + try: + prs.save(output_path) + logger.success(f"学生:{name},保存成功: {new_filename}") + except PermissionError: + logger.error(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。") + + logger.success("所有报告生成完毕!") + + except Exception as e: + 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,速度快) + """ + folder_path = os.path.abspath(folder_path) + if not os.path.exists(folder_path): + print("文件夹不存在") + return + + # 获取所有 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 + + 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): + print(f"[跳过] 已存在: {filename}") + continue + + print(f"正在转换: {filename} ...") + + try: + # 打开 -> 另存为 -> 关闭 + deck = powerpoint.Presentations.Open(ppt_path) + deck.SaveAs(pdf_path, 32) + deck.Close() + except Exception as e: + print(f"文件 {filename} 转换出错: {e}") + + except Exception as e: + print(f"PowerPoint 进程出错: {e}") + finally: + # 2. 退出应用 + if powerpoint: + powerpoint.Quit() + print("PowerPoint 已关闭,批量转换完成。") + + +# ========================================== +# 5. 生成属相(根据names.xlsx文件生成属相) +# ========================================== +def generate_zodiac(): + try: + # 1. 读取数据 + excel_path = config["excel_file"] + # sheet_name 根据实际情况修改,如果不确定可以用 sheet_name=0 读取第一个 + df = pd.read_excel(excel_path, sheet_name="Sheet1") + + # 2. 检查必要的列 + date_column = '生日' + target_column = '属相' + + if date_column not in df.columns: + 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) + + # 5. 保存结果 + save_path = excel_path + + try: + df.to_excel(save_path, index=False) + logger.success(f"所有属相已更新并写入文件:{save_path}") + logger.warning(f"请检查文件 {save_path} 修改日期格式。") + except PermissionError: + logger.error(f"保存失败!请先关闭 Excel 文件:{save_path}") + + except FileNotFoundError: + logger.error(f"找不到文件 {config.get('excel_file')}") + except Exception as e: + logger.error(f"程序运行出错: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + + diff --git a/utils/growt_utils.py b/utils/growt_utils.py new file mode 100644 index 0000000..afae6c1 --- /dev/null +++ b/utils/growt_utils.py @@ -0,0 +1,46 @@ +from rich.console import Console +from loguru import logger + +from config.config import load_config +from utils.pptx_utils import replace_text_in_slide, replace_picture + +# 如果你之前没有全局定义 console,这里定义一个 +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) + replace_text_in_slide(prs, 0, "class", class_name) + + +def replace_two_page(prs, comments, teacher_name): + """替换第二页信息""" + replace_text_in_slide(prs, 1, "comments", comments) + replace_text_in_slide(prs, 1, "teacher_name", teacher_name) + + +def replace_three_page(prs, info_dict, me_image): + """替换第三页信息""" + # 使用字典解包传递多个字段,减少参数数量 + fields = ["name", "english_name", "sex", "birthday", "zodiac", "friend", "hobby", "game", "food"] + for field in fields: + replace_text_in_slide(prs, 2, field, info_dict.get(field, "")) + if me_image: + replace_picture(prs, 2, "me_image", me_image) + else: + logger.warning(f"⚠️ 警告: {info_dict.get('name', '未知姓名')} 缺少个人照片('me_image')") + + +def replace_four_page(prs, class_image): + """替换第四页信息""" + replace_picture(prs, 3, "class_image", class_image) + + +def replace_five_page(prs, image1, image2): + """替换第五页信息""" + replace_picture(prs, 4, "image1", image1) + replace_picture(prs, 4, "image2", image2) diff --git a/utils/pdf_utils.py b/utils/pdf_utils.py new file mode 100644 index 0000000..52a6bff --- /dev/null +++ b/utils/pdf_utils.py @@ -0,0 +1,42 @@ +import os + +import comtypes.client + + +def ppt_to_pdf_single(ppt_path, pdf_path=None): + """ + 单个 PPT 转 PDF + :param ppt_path: PPT 文件路径 + :param pdf_path: PDF 输出路径 (可选,默认同名) + """ + ppt_path = os.path.abspath(ppt_path) # COM 接口必须使用绝对路径 + + if pdf_path is None: + pdf_path = os.path.splitext(ppt_path)[0] + ".pdf" + pdf_path = os.path.abspath(pdf_path) + + if not os.path.exists(ppt_path): + print(f"文件不存在: {ppt_path}") + return False + + powerpoint = None + try: + # 启动 PowerPoint 应用 + powerpoint = comtypes.client.CreateObject("PowerPoint.Application") + powerpoint.Visible = 1 # 设为可见,否则某些版本会报错 + + # 打开演示文稿 + deck = powerpoint.Presentations.Open(ppt_path) + + # 保存为 PDF (文件格式代码 32 代表 PDF) + deck.SaveAs(pdf_path, 32) + + deck.Close() + print(f"转换成功: {pdf_path}") + return True + except Exception as e: + print(f"转换失败: {str(e)}") + return False + finally: + if powerpoint: + powerpoint.Quit() diff --git a/utils/pef_utils.py b/utils/pef_utils.py deleted file mode 100644 index 8da1ec3..0000000 --- a/utils/pef_utils.py +++ /dev/null @@ -1,95 +0,0 @@ -import os - -import comtypes.client - - -def ppt_to_pdf_single(ppt_path, pdf_path=None): - """ - 单个 PPT 转 PDF - :param ppt_path: PPT 文件路径 - :param pdf_path: PDF 输出路径 (可选,默认同名) - """ - ppt_path = os.path.abspath(ppt_path) # COM 接口必须使用绝对路径 - - if pdf_path is None: - pdf_path = os.path.splitext(ppt_path)[0] + ".pdf" - pdf_path = os.path.abspath(pdf_path) - - if not os.path.exists(ppt_path): - print(f"文件不存在: {ppt_path}") - return False - - powerpoint = None - try: - # 启动 PowerPoint 应用 - powerpoint = comtypes.client.CreateObject("PowerPoint.Application") - powerpoint.Visible = 1 # 设为可见,否则某些版本会报错 - - # 打开演示文稿 - deck = powerpoint.Presentations.Open(ppt_path) - - # 保存为 PDF (文件格式代码 32 代表 PDF) - deck.SaveAs(pdf_path, 32) - - deck.Close() - print(f"转换成功: {pdf_path}") - return True - except Exception as e: - print(f"转换失败: {str(e)}") - return False - finally: - if powerpoint: - powerpoint.Quit() - - -def batch_convert_folder(folder_path): - """ - 【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint,速度快) - """ - folder_path = os.path.abspath(folder_path) - if not os.path.exists(folder_path): - print("文件夹不存在") - return - - # 获取所有 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 - - 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): - print(f"[跳过] 已存在: {filename}") - continue - - print(f"正在转换: {filename} ...") - - try: - # 打开 -> 另存为 -> 关闭 - deck = powerpoint.Presentations.Open(ppt_path) - deck.SaveAs(pdf_path, 32) - deck.Close() - except Exception as e: - print(f"文件 {filename} 转换出错: {e}") - - except Exception as e: - print(f"PowerPoint 进程出错: {e}") - finally: - # 2. 退出应用 - if powerpoint: - powerpoint.Quit() - print("PowerPoint 已关闭,批量转换完成。") diff --git a/utils/zodiac_utils.py b/utils/zodiac_utils.py new file mode 100644 index 0000000..5a76563 --- /dev/null +++ b/utils/zodiac_utils.py @@ -0,0 +1,30 @@ +import pandas as pd + +def calculate_zodiac(birth_date): + """ + 根据出生日期推断生肖 (基于公历年份简单算法) + 逻辑:1900年是鼠年,以此为基准进行模运算 + """ + if pd.isna(birth_date): + return "未知" + + # 确保是 datetime 对象,方便提取年份 + try: + birth_date = pd.to_datetime(birth_date) + except: + return "格式错误" + + year = birth_date.year + + # 生肖列表,顺序必须固定:鼠、牛、虎... + zodiacs = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'] + + # 算法核心:(年份 - 1900) % 12 得到索引 + # 1900年是鼠年,索引为0 + index = (year - 1900) % 12 + return zodiacs[index] + +# --- 主程序 --- + +# 1. 读取 Excel 文件 +file_path = 'names.xlsx' # 你的文件名