fix:优化启动方式
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,5 +10,7 @@ wheels/
|
||||
.venv
|
||||
output/*.pptx
|
||||
output/*.pdf
|
||||
data/images/*
|
||||
data/*.xlsx
|
||||
|
||||
config.toml
|
||||
120
.idea/workspace.xml
generated
Normal file
120
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="09d6a6cb-782b-4f40-bb60-5703c43250ec" name="更改" comment="fix:更新代码开源协议" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo"><![CDATA[{
|
||||
"associatedIndex": 0
|
||||
}]]></component>
|
||||
<component name="ProjectId" id="36hYCM0j8RdgslpqW2LFtFj8NEK" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"Python.UI.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"last_opened_file_path": "D:/working/tools/growth_report",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
<component name="RunManager">
|
||||
<configuration name="UI" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
|
||||
<module name="growth_report" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
</envs>
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/UI.py" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
<option name="EMULATE_TERMINAL" value="false" />
|
||||
<option name="MODULE_MODE" value="false" />
|
||||
<option name="REDIRECT_INPUT" value="false" />
|
||||
<option name="INPUT_FILE" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="Python.UI" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="默认任务">
|
||||
<changelist id="09d6a6cb-782b-4f40-bb60-5703c43250ec" name="更改" comment="" />
|
||||
<created>1765460141811</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1765460141811</updated>
|
||||
<workItem from="1765460142948" duration="1948000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="fix:优化一些BUG">
|
||||
<option name="closed" value="true" />
|
||||
<created>1765460523294</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1765460523294</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="fix:优化配置项目">
|
||||
<option name="closed" value="true" />
|
||||
<created>1765460614548</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1765460614548</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="fix:更新代码开源协议">
|
||||
<option name="closed" value="true" />
|
||||
<created>1765460996031</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1765460996031</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="4" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="fix:优化一些BUG" />
|
||||
<MESSAGE value="fix:优化配置项目" />
|
||||
<MESSAGE value="fix:更新代码开源协议" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix:更新代码开源协议" />
|
||||
</component>
|
||||
<component name="com.intellij.coverage.CoverageDataManagerImpl">
|
||||
<SUITE FILE_PATH="coverage/growth_report$UI.coverage" NAME="UI 覆盖结果" MODIFIED="1765460384581" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
|
||||
</component>
|
||||
</project>
|
||||
225
UI.py
225
UI.py
@@ -1,225 +0,0 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from tkinter import scrolledtext
|
||||
from tkinter import messagebox
|
||||
from tkinter import filedialog
|
||||
import threading
|
||||
import sys
|
||||
import time
|
||||
import queue
|
||||
import re
|
||||
|
||||
from loguru import logger
|
||||
from config.config import load_config
|
||||
|
||||
# 假设你的功能函数都在这里
|
||||
from utils.generate_utils import (
|
||||
generate_template,
|
||||
generate_comment_all,
|
||||
batch_convert_folder,
|
||||
generate_report,
|
||||
generate_zodiac,
|
||||
)
|
||||
from utils.file_utils import export_templates_folder, initialize_project, export_data
|
||||
|
||||
# ==========================================
|
||||
# 0. 全局配置与队列准备
|
||||
# ==========================================
|
||||
config = load_config("config.toml")
|
||||
log_queue = queue.Queue()
|
||||
|
||||
|
||||
def ansi_cleaner(text):
|
||||
"""【辅助函数】去除 loguru 输出中的 ANSI 颜色代码"""
|
||||
ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
|
||||
return ansi_escape.sub("", text)
|
||||
|
||||
|
||||
def queue_sink(message):
|
||||
"""【核心】loguru sink 回调"""
|
||||
clean_msg = ansi_cleaner(message)
|
||||
log_queue.put(clean_msg)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GUI 主程序类
|
||||
# ==========================================
|
||||
class ReportApp:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("🌱 尚城幼儿园成长报告助手")
|
||||
self.root.geometry("720x680") # 高度稍微增加一点以容纳分组
|
||||
|
||||
# 设置样式
|
||||
self.style = ttk.Style()
|
||||
self.style.theme_use("clam")
|
||||
self.style.configure("TButton", font=("微软雅黑", 10), padding=5)
|
||||
self.style.configure("Title.TLabel", font=("微软雅黑", 16, "bold"), foreground="#2E8B57")
|
||||
self.style.configure("Sub.TLabel", font=("微软雅黑", 9), foreground="gray")
|
||||
# LabelFrame 的标题样式
|
||||
self.style.configure("TLabelframe.Label", font=("微软雅黑", 10, "bold"), foreground="#0055a3")
|
||||
|
||||
# --- 1. 标题区域 ---
|
||||
header_frame = ttk.Frame(root, padding="10 15 10 5")
|
||||
header_frame.pack(fill=tk.X)
|
||||
ttk.Label(header_frame, text="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack()
|
||||
ttk.Label(header_frame, text="By 寒寒", style="Sub.TLabel").pack()
|
||||
|
||||
# --- 2. 按钮功能区域 (使用 LabelFrame 分组) ---
|
||||
|
||||
# 容器 Frame,给四周留点白
|
||||
main_content = ttk.Frame(root, padding=10)
|
||||
main_content.pack(fill=tk.X)
|
||||
|
||||
# === A组: 核心功能 ===
|
||||
func_btns = [
|
||||
("📁 生成图片路径", self.run_generate_folders),
|
||||
("🤖 生成评语 (AI)", self.run_generate_comments),
|
||||
("📊 生成报告 (PPT)", self.run_generate_report),
|
||||
("📑 格式转换 (PDF)", self.run_convert_pdf),
|
||||
("🐂 生肖转化 (生日)", self.run_zodiac),
|
||||
]
|
||||
self.create_btn_group(main_content, "🛠️ 核心功能", func_btns, columns=3)
|
||||
|
||||
# === B组: 数据导出 ===
|
||||
export_btns = [
|
||||
("📦 导出数据模板 (Zip)", self.run_export_data_folder),
|
||||
("📤 导出数据备份 (Zip)", self.run_export_data),
|
||||
]
|
||||
self.create_btn_group(main_content, "📦 数据管理", export_btns, columns=2)
|
||||
|
||||
# === C组: 系统设置 ===
|
||||
system_btns = [
|
||||
("⚠️ 初始化系统 (重置)", self.run_initialize_project),
|
||||
("🚪 退出系统", self.quit_app),
|
||||
]
|
||||
self.create_btn_group(main_content, "⚙️ 系统操作", system_btns, columns=2)
|
||||
|
||||
# --- 3. 日志输出区域 ---
|
||||
log_frame = ttk.LabelFrame(root, text="📝 系统实时日志", padding=10)
|
||||
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
self.log_text = scrolledtext.ScrolledText(
|
||||
log_frame, height=10, state="disabled", font=("Consolas", 9)
|
||||
)
|
||||
self.log_text.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 启动日志轮询
|
||||
self.root.after(100, self.poll_log_queue)
|
||||
logger.info("GUI 初始化完成,等待指令...")
|
||||
|
||||
def create_btn_group(self, parent, title, buttons, columns=2):
|
||||
"""
|
||||
辅助函数:快速创建分组按钮
|
||||
:param parent: 父容器
|
||||
:param title: 分组标题
|
||||
:param buttons: 按钮列表 [(text, func), ...]
|
||||
:param columns: 每行显示几个按钮
|
||||
"""
|
||||
frame = ttk.LabelFrame(parent, text=title, padding=10)
|
||||
frame.pack(fill=tk.X, pady=5) # 垂直堆叠
|
||||
|
||||
for index, (text, func) in enumerate(buttons):
|
||||
# 特殊处理:如果是"初始化"或"退出",可以用不同的样式(可选,这里暂不做)
|
||||
btn = ttk.Button(frame, text=text, command=func)
|
||||
|
||||
# 动态计算网格位置
|
||||
r, c = divmod(index, columns)
|
||||
btn.grid(row=r, column=c, padx=8, pady=5, sticky="ew")
|
||||
|
||||
# 配置列权重,让按钮自动填满宽度
|
||||
for i in range(columns):
|
||||
frame.columnconfigure(i, weight=1)
|
||||
|
||||
# --- 核心方法:轮询队列 ---
|
||||
def poll_log_queue(self):
|
||||
while not log_queue.empty():
|
||||
try:
|
||||
msg = log_queue.get_nowait()
|
||||
self.log_text.config(state="normal")
|
||||
self.log_text.insert(tk.END, msg)
|
||||
self.log_text.see(tk.END)
|
||||
self.log_text.config(state="disabled")
|
||||
except queue.Empty:
|
||||
break
|
||||
self.root.after(100, self.poll_log_queue)
|
||||
|
||||
# --- 线程包装器 ---
|
||||
def run_in_thread(self, target_func, *args):
|
||||
def thread_task():
|
||||
try:
|
||||
target_func(*args)
|
||||
logger.success("✅ 当前任务执行完毕。")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 发生错误: {str(e)}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
threading.Thread(target=thread_task, daemon=True).start()
|
||||
|
||||
# ==========================================
|
||||
# 按钮事件 (业务逻辑保持不变)
|
||||
# ==========================================
|
||||
def run_generate_folders(self):
|
||||
self.run_in_thread(generate_template)
|
||||
|
||||
def run_generate_comments(self):
|
||||
self.run_in_thread(generate_comment_all)
|
||||
|
||||
def run_generate_report(self):
|
||||
self.run_in_thread(generate_report)
|
||||
|
||||
def run_convert_pdf(self):
|
||||
self.run_in_thread(batch_convert_folder, config["output_folder"])
|
||||
|
||||
def run_zodiac(self):
|
||||
self.run_in_thread(generate_zodiac)
|
||||
|
||||
def run_export_data_folder(self):
|
||||
target_folder = filedialog.askdirectory(
|
||||
title="请选择导出数据保存的文件夹",
|
||||
initialdir=config.get("output_folder", ".")
|
||||
)
|
||||
if not target_folder:
|
||||
logger.warning("🚫 导出操作已取消")
|
||||
return
|
||||
logger.info(f"已选择保存路径: {target_folder}")
|
||||
self.run_in_thread(export_templates_folder, target_folder)
|
||||
|
||||
def run_initialize_project(self):
|
||||
self.run_in_thread(initialize_project)
|
||||
|
||||
def run_export_data(self):
|
||||
target_folder = filedialog.askdirectory(
|
||||
title="请选择导出数据保存的文件夹",
|
||||
initialdir=config.get("output_folder", ".")
|
||||
)
|
||||
if not target_folder:
|
||||
logger.warning("🚫 导出操作已取消")
|
||||
return
|
||||
logger.info(f"已选择保存路径: {target_folder}")
|
||||
self.run_in_thread(export_data, target_folder)
|
||||
|
||||
def quit_app(self):
|
||||
if messagebox.askokcancel("退出", "确定要退出系统吗?"):
|
||||
self.root.destroy()
|
||||
sys.exit()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 启动入口
|
||||
# ==========================================
|
||||
def applicationUI():
|
||||
logger.add(
|
||||
queue_sink,
|
||||
format="{time:HH:mm:ss} | {level: <8} | {message}",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
root = tk.Tk()
|
||||
app = ReportApp(root)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
applicationUI()
|
||||
BIN
data/names.xlsx
BIN
data/names.xlsx
Binary file not shown.
117
main.py
117
main.py
@@ -1,117 +0,0 @@
|
||||
from config.config import load_config
|
||||
from utils.generate_utils import (
|
||||
generate_template,
|
||||
generate_comment_all,
|
||||
generate_report,
|
||||
batch_convert_folder,
|
||||
generate_zodiac,
|
||||
)
|
||||
from utils.file_utils import export_templates_folder, initialize_project, export_data
|
||||
|
||||
config = load_config("config.yaml")
|
||||
|
||||
|
||||
def application():
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
from rich.align import Align
|
||||
from rich import box
|
||||
import sys
|
||||
|
||||
console = Console()
|
||||
|
||||
while True:
|
||||
console.clear()
|
||||
|
||||
# 1. 创建一个表格,不显示表头,使用圆角边框
|
||||
table = Table(box=None, show_header=False, padding=(0, 2))
|
||||
|
||||
# 2. 添加两列:序号列(居右),内容列(居左)
|
||||
table.add_column(justify="right", style="cyan bold")
|
||||
table.add_column(justify="left")
|
||||
|
||||
# 3. 添加行内容
|
||||
table.add_row("1.", "📁 生成图片路径(每一个幼儿一个图片文件夹)")
|
||||
table.add_row("2.", "🤖 生成评语(根据姓名、学段、性别)")
|
||||
table.add_row("3.", "📊 生成报告(根据表格生成)")
|
||||
table.add_row("4.", "📑 格式转换(PPT转PDF)")
|
||||
table.add_row("5.", "📑 生肖转化(根据生日)")
|
||||
table.add_row("6.", "📦 导出数据模板(Zip)")
|
||||
table.add_row("7.", "📦 初始化系统")
|
||||
table.add_row("8.", "📤 导出数据")
|
||||
table.add_row("9.", "🚪 退出系统")
|
||||
|
||||
# 4. 将表格放入面板,并居中显示
|
||||
panel = Panel(
|
||||
Align.center(table),
|
||||
title="[bold green]🌱 幼儿园成长报告助手",
|
||||
subtitle="[dim]By 寒寒",
|
||||
width=60,
|
||||
border_style="bright_blue",
|
||||
box=box.ROUNDED, # 圆角边框更柔和
|
||||
)
|
||||
|
||||
# 使用 Align.center 让整个菜单在屏幕中间显示
|
||||
console.print(Align.center(panel, vertical="middle"))
|
||||
console.print("\n") # 留点空隙
|
||||
|
||||
choice = Prompt.ask(
|
||||
"👉 请输入序号执行",
|
||||
choices=["1", "2", "3", "4", "5", "6", "7", "8", "9"],
|
||||
default="1",
|
||||
)
|
||||
|
||||
try:
|
||||
if choice == "1":
|
||||
console.rule("[bold cyan]正在执行: 生成模板[/]")
|
||||
with console.status(
|
||||
"[bold green]正在创建文件夹结构...[/]", spinner="dots"
|
||||
):
|
||||
generate_template()
|
||||
elif choice == "2":
|
||||
console.rule("[bold yellow]正在执行: AI 生成评语[/]")
|
||||
# 这里的 generate_comment_all 最好内部有进度条,或者简单的 print
|
||||
generate_comment_all()
|
||||
elif choice == "3":
|
||||
console.rule("[bold blue]正在执行: PPT 合成[/]")
|
||||
with console.status(
|
||||
"[bold blue]正在处理图片和文字...[/]", spinner="earth"
|
||||
):
|
||||
generate_report()
|
||||
elif choice == "4":
|
||||
console.rule("[bold magenta]正在执行: PDF 批量转换[/]")
|
||||
# 调用上面的批量转换函数,传入你的 output 文件夹路径
|
||||
batch_convert_folder(config["output_folder"])
|
||||
elif choice == "5":
|
||||
console.rule("[bold magenta]正在执行: 生肖转化[/]")
|
||||
# 调用上面的批量转换函数,传入你的 output 文件夹路径
|
||||
generate_zodiac()
|
||||
elif choice == "6":
|
||||
console.rule("[bold magenta]正在执行: 导出数据模板[/]")
|
||||
# 调用上面的批量转换函数,传入你的 output 文件夹路径
|
||||
export_templates_folder()
|
||||
elif choice == "7":
|
||||
console.rule("[bold magenta]正在执行: 初始化系统[/]")
|
||||
# 调用上面的批量转换函数,传入你的 output 文件夹路径
|
||||
initialize_project()
|
||||
elif choice == "8":
|
||||
console.rule("[bold magenta]正在执行: 导出数据[/]")
|
||||
# 调用上面的批量转换函数,传入你的 output 文件夹路径
|
||||
export_data()
|
||||
elif choice == "9":
|
||||
console.print("[bold red]👋 再见![/]")
|
||||
sys.exit()
|
||||
Prompt.ask("按 [bold]Enter[/] 键返回主菜单...")
|
||||
except Exception as e:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold red]❌ 发生错误:[/]\n{e}", title="Error", border_style="red"
|
||||
)
|
||||
)
|
||||
Prompt.ask("按 Enter 键继续...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
application()
|
||||
23
main.pyw
Normal file
23
main.pyw
Normal file
@@ -0,0 +1,23 @@
|
||||
import tkinter as tk
|
||||
from utils.log_handler import setup_logging
|
||||
from ui.app_window import ReportApp
|
||||
from loguru import logger
|
||||
|
||||
def main():
|
||||
# 1. 初始化日志
|
||||
setup_logging()
|
||||
logger.info("正在启动应用程序...")
|
||||
|
||||
# 2. 启动 UI
|
||||
root = tk.Tk()
|
||||
|
||||
# 这一行可以设置图标 (如果有 icon.ico 文件)
|
||||
# root.iconbitmap("icon.ico")
|
||||
|
||||
app = ReportApp(root)
|
||||
|
||||
# 3. 进入主循环
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"openpyxl>=3.1.5",
|
||||
"pandas>=2.3.3",
|
||||
"pandas-stubs==2.3.3.251201",
|
||||
"pillow>=12.0.0",
|
||||
"python-pptx>=1.0.2",
|
||||
"pywin32>=311",
|
||||
"rich>=14.2.0",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
chcp 65001 >nul
|
||||
:: ------------------------------------------------
|
||||
|
||||
title 幼儿园成长报告助手
|
||||
title 幼儿园成长报告助手启动器
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
@@ -13,7 +13,7 @@ echo 正在启动 幼儿园成长报告助手
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
:: 检查 uv 是否安装
|
||||
:: 1. 检查 uv 是否安装
|
||||
uv --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] 未检测到 uv 工具!
|
||||
@@ -22,21 +22,28 @@ if %errorlevel% neq 0 (
|
||||
exit /b
|
||||
)
|
||||
|
||||
echo [INFO] 环境检查通过,正在运行主程序...
|
||||
echo [INFO] 环境检查通过...
|
||||
|
||||
:: 2. 检查依赖是否安装 (可选,防止第一次运行报错)
|
||||
:: 如果你有 pyproject.toml,uv run 会自动处理,这一步可以省略
|
||||
:: 这里为了保险,检查一下 loguru 是否存在,不存在则自动安装基础依赖
|
||||
uv pip show loguru >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [INFO] 首次运行,正在安装依赖...
|
||||
uv pip install loguru toml pandas pillow openpyxl python-pptx
|
||||
)
|
||||
|
||||
echo [INFO] 正在拉起主程序...
|
||||
echo ---------------------------------------------------
|
||||
|
||||
:: 这里的 gui_app.py 就是你刚才保存的那个带界面的 Python 文件名
|
||||
:: 如果你的文件名不一样,请修改下面这一行
|
||||
uv run UI.py
|
||||
:: =======================================================
|
||||
:: 【关键修改】路径改为根目录的 main.pyw
|
||||
:: 使用 start 命令启动,这样黑色的 CMD 窗口可以随后立即关闭
|
||||
:: =======================================================
|
||||
start "" uv run main.pyw
|
||||
|
||||
:: 错误捕获
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ---------------------------------------------------
|
||||
echo [ERROR] 程序异常退出 (代码: %errorlevel%)
|
||||
echo 请检查上方报错信息。
|
||||
pause
|
||||
) else (
|
||||
echo.
|
||||
echo [INFO] 程序已正常结束。
|
||||
)
|
||||
:: 等待 1 秒确保启动
|
||||
timeout /t 1 >nul
|
||||
|
||||
:: 退出 CMD 窗口 (让用户只看到 GUI)
|
||||
exit
|
||||
BIN
templates/K4A 大班幼儿学期发展报告.pptx
Normal file
BIN
templates/K4A 大班幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/横板/中班 幼儿学期发展报告.pptx
Normal file
BIN
templates/横板/中班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/横板/大班 幼儿学期发展报告.pptx
Normal file
BIN
templates/横板/大班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/横板/小班 幼儿学期发展报告.pptx
Normal file
BIN
templates/横板/小班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
BIN
templates/竖版/中班 幼儿学期发展报告.pptx
Normal file
BIN
templates/竖版/中班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
Binary file not shown.
BIN
templates/竖版/小班 幼儿学期发展报告.pptx
Normal file
BIN
templates/竖版/小班 幼儿学期发展报告.pptx
Normal file
Binary file not shown.
161
ui/app_window.py
Normal file
161
ui/app_window.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox, filedialog
|
||||
import threading
|
||||
import queue
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
from config.config import load_config
|
||||
from utils.log_handler import log_queue
|
||||
|
||||
# 导入业务逻辑
|
||||
from utils.generate_utils import (
|
||||
generate_template,
|
||||
generate_comment_all,
|
||||
batch_convert_folder,
|
||||
generate_report,
|
||||
generate_zodiac,
|
||||
)
|
||||
from utils.file_utils import export_templates_folder, initialize_project, export_data
|
||||
|
||||
config = load_config("config.toml")
|
||||
|
||||
class ReportApp:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("🌱 尚城幼儿园成长报告助手")
|
||||
self.root.geometry("720x760")
|
||||
|
||||
# 线程控制
|
||||
self.stop_event = threading.Event()
|
||||
self.is_running = False
|
||||
|
||||
self._setup_ui()
|
||||
self._start_log_polling()
|
||||
|
||||
def _setup_ui(self):
|
||||
# 样式配置
|
||||
self.style = ttk.Style()
|
||||
self.style.theme_use("clam")
|
||||
self.style.configure("TButton", font=("微软雅黑", 10), padding=5)
|
||||
self.style.configure("Title.TLabel", font=("微软雅黑", 16, "bold"), foreground="#2E8B57")
|
||||
self.style.configure("Stop.TButton", foreground="red", font=("微软雅黑", 10, "bold"))
|
||||
|
||||
# 1. 标题
|
||||
header = ttk.Frame(self.root, padding="10 15 10 5")
|
||||
header.pack(fill=tk.X)
|
||||
ttk.Label(header, text="🌱 尚城幼儿园成长报告助手", style="Title.TLabel").pack()
|
||||
ttk.Label(header, text="By 寒寒 | 这里的每一份评语都充满爱意", font=("微软雅黑", 9), foreground="gray").pack()
|
||||
|
||||
# 2. 功能区容器
|
||||
main_content = ttk.Frame(self.root, padding=10)
|
||||
main_content.pack(fill=tk.X)
|
||||
|
||||
# === A组: 核心功能 ===
|
||||
self._create_btn_group(main_content, "🛠️ 核心功能", [
|
||||
("📁 生成图片路径", lambda: self.run_task(generate_template)),
|
||||
("🤖 生成评语 (AI)", lambda: self.run_task(generate_comment_all)),
|
||||
("📊 生成报告 (PPT)", lambda: self.run_task(generate_report)),
|
||||
("📑 格式转换 (PDF)", lambda: self.run_task(batch_convert_folder, config.get("output_folder"))),
|
||||
("🐂 生肖转化 (生日)", lambda: self.run_task(generate_zodiac)),
|
||||
], columns=3)
|
||||
|
||||
# === B组: 数据管理 ===
|
||||
self._create_btn_group(main_content, "📦 数据管理", [
|
||||
("📦 导出模板 (Zip)", self.run_export_template),
|
||||
("📤 导出备份 (Zip)", self.run_export_data),
|
||||
], columns=2)
|
||||
|
||||
# === C组: 系统操作 (含停止按钮) ===
|
||||
self._create_btn_group(main_content, "⚙️ 系统操作", [
|
||||
("⛔ 停止当前任务", self.stop_current_task),
|
||||
("⚠️ 初始化系统", self.run_init),
|
||||
("🚪 退出系统", self.quit_app),
|
||||
], columns=3, special_styles={"⛔ 停止当前任务": "Stop.TButton"})
|
||||
|
||||
# 3. 日志区
|
||||
log_frame = ttk.LabelFrame(self.root, text="📝 系统实时日志", padding=10)
|
||||
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
self.log_text = scrolledtext.ScrolledText(log_frame, height=10, state="disabled", font=("Consolas", 9))
|
||||
self.log_text.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def _create_btn_group(self, parent, title, buttons, columns=2, special_styles=None):
|
||||
frame = ttk.LabelFrame(parent, text=title, padding=10)
|
||||
frame.pack(fill=tk.X, pady=5)
|
||||
special_styles = special_styles or {}
|
||||
|
||||
for i, (text, func) in enumerate(buttons):
|
||||
style = special_styles.get(text, "TButton")
|
||||
btn = ttk.Button(frame, text=text, command=func, style=style)
|
||||
r, c = divmod(i, columns)
|
||||
btn.grid(row=r, column=c, padx=5, pady=5, sticky="ew")
|
||||
|
||||
for i in range(columns):
|
||||
frame.columnconfigure(i, weight=1)
|
||||
|
||||
def _start_log_polling(self):
|
||||
while not log_queue.empty():
|
||||
try:
|
||||
msg = log_queue.get_nowait()
|
||||
self.log_text.config(state="normal")
|
||||
self.log_text.insert(tk.END, msg)
|
||||
self.log_text.see(tk.END)
|
||||
self.log_text.config(state="disabled")
|
||||
except queue.Empty:
|
||||
break
|
||||
self.root.after(100, self._start_log_polling)
|
||||
|
||||
# --- 任务运行核心逻辑 ---
|
||||
def run_task(self, target_func, *args, **kwargs):
|
||||
if self.is_running:
|
||||
messagebox.showwarning("忙碌中", "请先等待当前任务完成或点击【停止当前任务】")
|
||||
return
|
||||
|
||||
self.stop_event.clear()
|
||||
self.is_running = True
|
||||
|
||||
def thread_worker():
|
||||
try:
|
||||
# 尝试传入 stop_event
|
||||
try:
|
||||
target_func(*args, stop_event=self.stop_event, **kwargs)
|
||||
except TypeError:
|
||||
# 如果旧函数不支持 stop_event,则普通运行
|
||||
target_func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"任务出错: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
self.is_running = False
|
||||
logger.info("--- 就绪 ---")
|
||||
|
||||
threading.Thread(target=thread_worker, daemon=True).start()
|
||||
|
||||
def stop_current_task(self):
|
||||
if not self.is_running:
|
||||
return
|
||||
if messagebox.askyesno("确认", "确定要中断当前任务吗?"):
|
||||
self.stop_event.set()
|
||||
logger.warning("正在发送停止信号...")
|
||||
|
||||
# --- 具体按钮事件 ---
|
||||
def run_export_template(self):
|
||||
path = filedialog.askdirectory()
|
||||
if path: self.run_task(export_templates_folder, path)
|
||||
|
||||
def run_export_data(self):
|
||||
path = filedialog.askdirectory()
|
||||
if path: self.run_task(export_data, path)
|
||||
|
||||
def run_init(self):
|
||||
if messagebox.askokcancel("警告", "确定重置系统吗?数据将丢失!"):
|
||||
self.run_task(initialize_project)
|
||||
|
||||
def quit_app(self):
|
||||
if self.is_running:
|
||||
messagebox.showwarning("提示", "请先停止任务")
|
||||
return
|
||||
self.root.destroy()
|
||||
sys.exit()
|
||||
@@ -6,6 +6,8 @@ import platform
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def get_system_fonts():
|
||||
"""获取系统中可用的字体列表"""
|
||||
@@ -21,7 +23,7 @@ def get_system_fonts():
|
||||
for font_file in folder.glob(ext):
|
||||
fonts.add(font_file.stem)
|
||||
except Exception as e:
|
||||
print(f"读取系统字体时出错: {e}")
|
||||
logger.error(f"读取系统字体时出错: {e}")
|
||||
fonts = {"微软雅黑", "宋体", "黑体", "Arial", "Microsoft YaHei"}
|
||||
return fonts
|
||||
|
||||
@@ -40,14 +42,14 @@ def is_font_available(font_name):
|
||||
def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
"""从指定目录安装字体到系统"""
|
||||
if platform.system() != "Windows":
|
||||
print("字体安装功能目前仅支持Windows系统")
|
||||
logger.success("字体安装功能目前仅支持Windows系统")
|
||||
return False
|
||||
|
||||
target_font_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
|
||||
target_font_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not os.path.exists(fonts_dir):
|
||||
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
|
||||
logger.error(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
|
||||
return False
|
||||
|
||||
font_files = []
|
||||
@@ -55,7 +57,7 @@ def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
|
||||
|
||||
if not font_files:
|
||||
print(f"在 {fonts_dir} 目录中未找到字体文件")
|
||||
logger.error(f"在 {fonts_dir} 目录中未找到字体文件")
|
||||
return False
|
||||
|
||||
installed_count = 0
|
||||
@@ -64,12 +66,12 @@ def install_fonts_from_directory(fonts_dir="fonts"):
|
||||
target_path = target_font_dir / font_file.name
|
||||
if not target_path.exists():
|
||||
shutil.copy2(font_file, target_path)
|
||||
print(f"已安装字体: {font_file.name}")
|
||||
logger.success(f"已安装字体: {font_file.name}")
|
||||
installed_count += 1
|
||||
except Exception as e:
|
||||
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
|
||||
logger.error(f"安装字体 {font_file.name} 时出错: {str(e)}")
|
||||
|
||||
if installed_count > 0:
|
||||
print(f"共安装了 {installed_count} 个新字体文件,建议重启Python环境")
|
||||
logger.success(f"共安装了 {installed_count} 个新字体文件,建议重启Python环境")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -6,6 +6,7 @@ import pandas as pd
|
||||
from loguru import logger
|
||||
from pptx import Presentation
|
||||
from rich.console import Console
|
||||
import traceback
|
||||
|
||||
import comtypes.client
|
||||
from config.config import load_config
|
||||
@@ -65,8 +66,6 @@ def generate_template():
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
# 打印详细报错位置,方便调试
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
@@ -83,11 +82,10 @@ def generate_comment_all():
|
||||
if "评价" not in df.columns:
|
||||
df["评价"] = ""
|
||||
|
||||
# --- 获取总行数,用于日志 ---
|
||||
# 强制将“评价”列转换为 object 类型
|
||||
# 获取学生数据行数
|
||||
total_count = len(df)
|
||||
logger.info(f"开始生成学生评语,共 {total_count} 位学生...")
|
||||
|
||||
# 强制将“评价”列转换为 object 类型
|
||||
df["评价"] = df["评价"].astype("object")
|
||||
# --- 遍历 DataFrame 的索引 (index) ---
|
||||
# 这样我们可以通过索引 i 精准地把数据写回某一行
|
||||
@@ -121,28 +119,27 @@ def generate_comment_all():
|
||||
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
|
||||
|
||||
try:
|
||||
# 调用你的生成函数,并【接收返回值】
|
||||
# 注意:这里假设 generate_comment 返回的是清洗后的字符串
|
||||
# 调用AI大模型生成内容
|
||||
generated_text = generate_comment(
|
||||
name, config["age_group"], traits, sex
|
||||
)
|
||||
|
||||
# --- 将结果写入 DataFrame ---
|
||||
df.at[i, "评价"] = generated_text
|
||||
|
||||
if generated_text:
|
||||
# 赋值
|
||||
df.at[i, "评价"] = str(generated_text).strip()
|
||||
else:
|
||||
df.at[i, "评价"] = "" # 防空处理
|
||||
logger.success(f"学生:{name},评语生成完毕")
|
||||
|
||||
# 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失
|
||||
# 可选:每生成 5 个就保存一次
|
||||
if (i + 1) % 5 == 0:
|
||||
df.to_excel(excel_path, index=False)
|
||||
logger.info("--- 阶段性保存成功 ---")
|
||||
|
||||
time.sleep(1) # 避免触发API速率限制
|
||||
|
||||
logger.success("✅ 阶段性保存成功")
|
||||
# 避免触发API速率限制
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.error(f"学生:{name},生成评语出错: {str(e)}")
|
||||
|
||||
# --- 修改点 4: 循环结束后最终保存文件 ---
|
||||
# --- 循环结束后最终保存文件 ---
|
||||
# index=False 表示不把 pandas 的索引 (0,1,2...) 写到 Excel 第一列
|
||||
df.to_excel(excel_path, index=False)
|
||||
logger.success(f"所有评语已生成并写入文件:{excel_path}")
|
||||
@@ -151,8 +148,6 @@ def generate_comment_all():
|
||||
logger.error(f"保存失败!请先关闭 Excel 文件:{config['excel_file']}")
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
@@ -306,9 +301,6 @@ def generate_report():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
# 打印详细报错位置,方便调试
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
@@ -320,9 +312,8 @@ def batch_convert_folder(folder_path):
|
||||
【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint,速度快)
|
||||
已修复多线程 CoInitialize 报错,并适配 GUI 日志
|
||||
"""
|
||||
# 【核心修复 1】子线程初始化 COM 组件
|
||||
# 子线程初始化 COM 组件
|
||||
pythoncom.CoInitialize()
|
||||
|
||||
try:
|
||||
folder_path = os.path.abspath(folder_path)
|
||||
if not os.path.exists(folder_path):
|
||||
@@ -429,6 +420,4 @@ def generate_zodiac():
|
||||
logger.error(f"找不到文件 {config.get('excel_file')}")
|
||||
except Exception as e:
|
||||
logger.error(f"程序运行出错: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
from PIL import Image, ExifTags
|
||||
import io
|
||||
|
||||
|
||||
def find_image_path(folder, base_filename):
|
||||
@@ -26,3 +28,39 @@ def find_image_path(folder, base_filename):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_corrected_image_stream(img_path):
|
||||
"""
|
||||
读取图片,根据 EXIF 信息修正旋转方向,并返回 BytesIO 对象。
|
||||
这样不需要修改原文件,直接在内存中处理。
|
||||
"""
|
||||
image = Image.open(img_path)
|
||||
|
||||
# 获取 EXIF 数据
|
||||
try:
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
if ExifTags.TAGS[orientation] == 'Orientation':
|
||||
break
|
||||
|
||||
exif = dict(image._getexif().items())
|
||||
|
||||
if exif[orientation] == 3:
|
||||
image = image.rotate(180, expand=True)
|
||||
elif exif[orientation] == 6:
|
||||
image = image.rotate(270, expand=True)
|
||||
elif exif[orientation] == 8:
|
||||
image = image.rotate(90, expand=True)
|
||||
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
# 如果图片没有 EXIF 数据或不需要旋转,则忽略
|
||||
pass
|
||||
|
||||
# 将处理后的图片保存到内存流中
|
||||
image_stream = io.BytesIO()
|
||||
# 注意:保存时保持原格式,如果是 PNG 等无 EXIF 的格式会自动处理
|
||||
img_format = image.format if image.format else 'JPEG'
|
||||
image.save(image_stream, format=img_format)
|
||||
image_stream.seek(0) # 指针回到开头
|
||||
|
||||
return image_stream
|
||||
27
utils/log_handler.py
Normal file
27
utils/log_handler.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import queue
|
||||
import re
|
||||
from loguru import logger
|
||||
|
||||
# 全局日志队列
|
||||
log_queue = queue.Queue()
|
||||
|
||||
def ansi_cleaner(text):
|
||||
"""去除 loguru 输出中的颜色代码,防止在 UI 显示乱码"""
|
||||
ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
|
||||
return ansi_escape.sub("", text)
|
||||
|
||||
def queue_sink(message):
|
||||
"""Loguru 的回调函数"""
|
||||
clean_msg = ansi_cleaner(message)
|
||||
log_queue.put(clean_msg)
|
||||
|
||||
def setup_logging():
|
||||
"""配置日志系统"""
|
||||
# 清除默认的控制台输出,防止干扰
|
||||
logger.remove()
|
||||
|
||||
# 添加队列输出 (给 UI 用)
|
||||
logger.add(queue_sink, format="{time:HH:mm:ss} | {level: <8} | {message}", level="INFO")
|
||||
|
||||
# 添加文件输出 (给开发者排查用)
|
||||
# logger.add("logs/app_runtime.log", rotation="1 MB", encoding="utf-8", level="DEBUG")
|
||||
@@ -3,7 +3,10 @@
|
||||
# ==========================================
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from utils.font_utils import is_font_available
|
||||
from utils.image_utils import get_corrected_image_stream
|
||||
|
||||
|
||||
def replace_text_in_slide(prs, slide_index, placeholder, text):
|
||||
@@ -94,18 +97,36 @@ def replace_text_in_slide(prs, slide_index, placeholder, text):
|
||||
|
||||
|
||||
def replace_picture(prs, slide_index, placeholder, img_path):
|
||||
"""在指定幻灯片中替换指定占位符的图片"""
|
||||
"""在指定幻灯片中替换指定占位符的图片(包含自动旋转修复)"""
|
||||
if not os.path.exists(img_path):
|
||||
print(f"警告: 图片路径不存在 {img_path}")
|
||||
logger.warning(f"警告: 图片路径不存在 {img_path}")
|
||||
return
|
||||
|
||||
slide = prs.slides[slide_index]
|
||||
sp_tree = slide.shapes._spTree
|
||||
|
||||
target_shape = None
|
||||
target_index = -1
|
||||
|
||||
# 1. 先找到目标形状和它的索引
|
||||
for i, shape in enumerate(slide.shapes):
|
||||
if shape.name == placeholder:
|
||||
left, top, width, height = shape.left, shape.top, shape.width, shape.height
|
||||
sp_tree.remove(shape._element)
|
||||
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
|
||||
sp_tree.insert(i, new_shape._element)
|
||||
target_shape = shape
|
||||
target_index = i
|
||||
break
|
||||
|
||||
if target_shape:
|
||||
# 获取原位置信息
|
||||
left, top, width, height = target_shape.left, target_shape.top, target_shape.width, target_shape.height
|
||||
|
||||
# 2. 获取修正后的图片流
|
||||
img_stream = get_corrected_image_stream(img_path)
|
||||
|
||||
# 3. 移除旧形状
|
||||
sp_tree.remove(target_shape._element)
|
||||
|
||||
# 4. 插入新图片 (使用流而不是路径)
|
||||
new_shape = slide.shapes.add_picture(img_stream, left, top, width, height)
|
||||
|
||||
# 5. 恢复层级位置 (z-order)
|
||||
sp_tree.insert(target_index, new_shape._element)
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -121,6 +121,7 @@ dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-pptx" },
|
||||
{ name = "pywin32" },
|
||||
{ name = "rich" },
|
||||
@@ -136,6 +137,7 @@ requires-dist = [
|
||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||
{ name = "pandas", specifier = ">=2.3.3" },
|
||||
{ name = "pandas-stubs", specifier = "==2.3.3.251201" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "python-pptx", specifier = ">=1.0.2" },
|
||||
{ name = "pywin32", specifier = ">=311" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
|
||||
Reference in New Issue
Block a user