From 87cea6ed86cf94d87e2cc8156fbdc66fda462e3a Mon Sep 17 00:00:00 2001 From: dmy Date: Wed, 7 Jan 2026 12:47:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=B3=BB=E7=BB=9F=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重新启用run.io_bound()调用,使用后台线程执行PowerShell脚本 - 为所有文件保存操作添加按钮防重复点击功能 - 新增win32_helper模块,提供Win32 API和COM接口的文件对话框 - 简化导出最佳方案DXF的代码结构 - 改进异步操作和错误处理机制 --- gui.py | 197 ++++++++++++++++++---------------------- win32_helper.py | 232 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 113 deletions(-) create mode 100644 win32_helper.py diff --git a/gui.py b/gui.py index cbbae5c..75564af 100644 --- a/gui.py +++ b/gui.py @@ -10,7 +10,7 @@ matplotlib.use("Agg") import matplotlib.backends.backend_svg import matplotlib.pyplot as plt import pandas as pd -from nicegui import app, events, ui +from nicegui import app, events, ui, run from main import ( compare_design_methods, @@ -347,7 +347,7 @@ def index(): except Exception as ex: ui.notify(f"上传处理失败: {ex}", type="negative") - async def save_file_with_dialog(filename, callback, file_filter="All files (*.*)"): + async def save_file_with_dialog(filename, callback, file_filter="All files (*.*)", sender=None): """ 跨平台文件保存助手。 如果是原生模式,弹出系统保存对话框。 @@ -356,77 +356,84 @@ def index(): :param filename: 默认文件名 :param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None :param file_filter: 格式如 "Excel Files (*.xlsx)" + :param sender: 触发该操作的 UI 组件,用于在操作期间禁用以防重复点击 """ - # 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows) - # 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError - import platform - import subprocess + if sender: + sender.disable() + try: + # 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows) + import platform + import subprocess - if platform.system() == "Windows": - try: - # 构建 PowerShell 脚本 - # 注意:过滤器格式为 "描述|*.ext|所有文件|*.*" - ps_filter = file_filter.replace("(", "|").replace(")", "") - if "|" not in ps_filter: - ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}" - - # 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx") - # 这里做一个简化的映射,确保格式正确 - if "Excel" in file_filter: - ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*" - elif "DXF" in file_filter: - ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*" - elif "ZIP" in file_filter: - ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*" - else: - ps_filter = "All Files (*.*)|*.*" + if platform.system() == "Windows": + try: + # 构建 PowerShell 脚本 + # 注意:过滤器格式为 "描述|*.ext|所有文件|*.*" + ps_filter = file_filter.replace("(", "|").replace(")", "") + if "|" not in ps_filter: + ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}" + + # 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx") + # 这里做一个简化的映射,确保格式正确 + if "Excel" in file_filter: + ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*" + elif "DXF" in file_filter: + ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*" + elif "ZIP" in file_filter: + ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*" + else: + ps_filter = "All Files (*.*)|*.*" - ps_script = f""" - Add-Type -AssemblyName System.Windows.Forms - $d = New-Object System.Windows.Forms.SaveFileDialog - $d.Filter = "{ps_filter}" - $d.FileName = "{filename}" - $d.Title = "保存文件" - if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ - Write-Output $d.FileName - }} - """ + ps_script = f""" + Add-Type -AssemblyName System.Windows.Forms + $d = New-Object System.Windows.Forms.SaveFileDialog + $d.Filter = "{ps_filter}" + $d.FileName = "{filename}" + $d.Title = "保存文件" + if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ + Write-Output $d.FileName + }} + """ - # 运行 PowerShell - # 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁) - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - - print("DEBUG: invoking PowerShell SaveFileDialog...") - - # 在 native 模式下直接同步执行,不使用 run.io_bound() - result = subprocess.run( - ["powershell", "-Command", ps_script], - capture_output=True, - text=True, - startupinfo=startupinfo - ) - save_path = result.stdout.strip() + # 运行 PowerShell + # 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁) + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + print("DEBUG: invoking PowerShell SaveFileDialog...") + + # 使用 run.io_bound 在后台线程执行,避免阻塞主事件循环 + # 这样按钮的禁用状态可以立即同步到前端 + result = await run.io_bound( + subprocess.run, + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + startupinfo=startupinfo + ) + save_path = result.stdout.strip() + if save_path: + print(f"DEBUG: PowerShell returned path: {save_path}") + await callback(save_path) + ui.notify(f"文件已保存至: {save_path}", type="positive") + return + else: + print("DEBUG: PowerShell dialog cancelled or empty result.") + # 用户取消,直接返回,不回退 + return - if save_path: - print(f"DEBUG: PowerShell returned path: {save_path}") - await callback(save_path) - ui.notify(f"文件已保存至: {save_path}", type="positive") - return - else: - print("DEBUG: PowerShell dialog cancelled or empty result.") - # 用户取消,直接返回,不回退 - return + except Exception as e: + print(f"PowerShell dialog failed: {e}") + # 出错则回退到 ui.download - except Exception as e: - print(f"PowerShell dialog failed: {e}") - # 出错则回退到 ui.download - - # 统一回退方案:浏览器下载 - print("DEBUG: Using ui.download fallback") - temp_path = os.path.join(state["temp_dir"], filename) - await callback(temp_path) - ui.download(temp_path) + # 统一回退方案:浏览器下载 + print("DEBUG: Using ui.download fallback") + temp_path = os.path.join(state["temp_dir"], filename) + await callback(temp_path) + ui.download(temp_path) + finally: + if sender: + sender.enable() def update_export_buttons(): if refs["export_row"]: @@ -468,9 +475,9 @@ def index(): # 如果不存在,重新生成 export_all_scenarios_to_excel(state["results"], path) - async def on_click_excel(): + async def on_click_excel(e): await save_file_with_dialog( - default_excel_name, save_excel, "Excel Files (*.xlsx)" + default_excel_name, save_excel, "Excel Files (*.xlsx)", sender=e.sender ) ui.button( @@ -479,41 +486,7 @@ def index(): ).props("icon=download") # --- 导出推荐方案 DXF --- - def export_best_dxf(): - if state["substation"] is not None: - safe_name = "".join( - [ - c - for c in best_res["name"] - if c.isalnum() or c in (" ", "-", "_") - ] - ).strip() - default_name = f"{file_prefix}_best_{safe_name}.dxf" - - async def save_dxf(path): - export_to_dxf( - best_res["turbines"], - state["substation"], - best_res["eval"]["details"], - path, - ) - - # 包装为 async 任务,并在 NiceGUI 事件循环中执行 - async def run_save(): - await save_file_with_dialog( - default_name, save_dxf, "DXF Files (*.dxf)" - ) - - # 这里的 export_best_dxf 本身是普通函数,绑定到 on_click - # 但我们需要它执行异步操作。最简单的是让 export_best_dxf 变为 async - # 或者在这里直接调用 run_save (但这在普通函数里不行) - # 更好的方法是将 export_best_dxf 定义为 async,如下所示 - return run_save() - else: - ui.notify("缺少升压站数据,无法导出 DXF", type="negative") - - # 将 export_best_dxf 改为 async 并重命名,以便直接用作回调 - async def on_click_best_dxf(): + async def on_click_best_dxf(e): if state["substation"] is not None: safe_name = "".join( [ @@ -533,7 +506,7 @@ def index(): ) await save_file_with_dialog( - default_name, save_dxf, "DXF Files (*.dxf)" + default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender ) else: ui.notify("缺少升压站数据,无法导出 DXF", type="negative") @@ -543,7 +516,7 @@ def index(): ).props("icon=architecture color=accent") # --- 导出选中方案 DXF --- - async def on_click_selected_dxf(): + async def on_click_selected_dxf(e): if not refs["results_table"] or not refs["results_table"].selected: ui.notify("请先在上方表格中选择一个方案", type="warning") return @@ -573,7 +546,7 @@ def index(): ) await save_file_with_dialog( - default_name, save_dxf, "DXF Files (*.dxf)" + default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender ) else: ui.notify( @@ -588,7 +561,7 @@ def index(): refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})") # --- 导出全部 ZIP --- - async def on_click_all_dxf(): + async def on_click_all_dxf(e): if not state["results"] or state["substation"] is None: ui.notify("无方案数据可导出", type="warning") return @@ -634,7 +607,7 @@ def index(): except: pass - await save_file_with_dialog(default_name, save_zip, "ZIP Files (*.zip)") + await save_file_with_dialog(default_name, save_zip, "ZIP Files (*.zip)", sender=e.sender) ui.button("导出全部方案 DXF (ZIP)", on_click=on_click_all_dxf).props( "icon=folder_zip color=secondary" @@ -688,8 +661,6 @@ def index(): import queue - from nicegui import run - async def run_analysis(): if not state["excel_path"]: ui.notify("请先上传 Excel 坐标文件!", type="warning") @@ -879,7 +850,7 @@ def index(): # 使用 items-stretch 确保所有子元素高度一致 with ui.row().classes("w-full items-stretch gap-4"): # 1. 导出模板按钮 - async def export_template(): + async def export_template(e): import shutil from generate_template import create_template @@ -900,7 +871,7 @@ def index(): raise FileNotFoundError("无法生成模板文件") await save_file_with_dialog( - "coordinates.xlsx", save_template, "Excel Files (*.xlsx)" + "coordinates.xlsx", save_template, "Excel Files (*.xlsx)", sender=e.sender ) ui.button("导出 Excel 模板", on_click=export_template).classes( diff --git a/win32_helper.py b/win32_helper.py new file mode 100644 index 0000000..dfb9ec6 --- /dev/null +++ b/win32_helper.py @@ -0,0 +1,232 @@ +import ctypes +import ctypes.wintypes +import os + +def show_save_dialog_win32(): + """ + 使用 ctypes 直接调用 Windows API (GetSaveFileNameW) + 不需要子进程,可以在线程中运行 (run.io_bound) + """ + try: + # 定义 OPENFILENAME 结构体 + class OPENFILENAME(ctypes.Structure): + _fields_ = [ + ("lStructSize", ctypes.wintypes.DWORD), + ("hwndOwner", ctypes.wintypes.HWND), + ("hInstance", ctypes.wintypes.HINSTANCE), + ("lpstrFilter", ctypes.wintypes.LPCWSTR), + ("lpstrCustomFilter", ctypes.wintypes.LPWSTR), + ("nMaxCustFilter", ctypes.wintypes.DWORD), + ("nFilterIndex", ctypes.wintypes.DWORD), + ("lpstrFile", ctypes.wintypes.LPWSTR), + ("nMaxFile", ctypes.wintypes.DWORD), + ("lpstrFileTitle", ctypes.wintypes.LPWSTR), + ("nMaxFileTitle", ctypes.wintypes.DWORD), + ("lpstrInitialDir", ctypes.wintypes.LPCWSTR), + ("lpstrTitle", ctypes.wintypes.LPCWSTR), + ("Flags", ctypes.wintypes.DWORD), + ("nFileOffset", ctypes.wintypes.WORD), + ("nFileExtension", ctypes.wintypes.WORD), + ("lpstrDefExt", ctypes.wintypes.LPCWSTR), + ("lCustData", ctypes.wintypes.LPARAM), + ("lpfnHook", ctypes.wintypes.LPVOID), + ("lpTemplateName", ctypes.wintypes.LPCWSTR), + # 还有更多字段,但这通常足够了 + # ("pvReserved", ctypes.wintypes.LPVOID), + # ("dwReserved", ctypes.wintypes.DWORD), + # ("FlagsEx", ctypes.wintypes.DWORD), + ] + + # 准备缓冲区 + filename_buffer = ctypes.create_unicode_buffer(260) # MAX_PATH + # 设置初始文件名 + filename_buffer.value = "win32_save.xlsx" + + # 准备过滤器 (用 \0 分隔) + # 格式: "描述\0模式\0描述\0模式\0\0" + filter_str = "Excel Files (*.xlsx)\0*.xlsx\0All Files (*.*)\0*.*\0\0" + + ofn = OPENFILENAME() + ofn.lStructSize = ctypes.sizeof(OPENFILENAME) + ofn.hwndOwner = 0 # NULL + ofn.lpstrFilter = filter_str + ofn.lpstrFile = ctypes.cast(filename_buffer, ctypes.wintypes.LPWSTR) + ofn.nMaxFile = 260 + ofn.lpstrDefExt = "xlsx" + ofn.lpstrTitle = "保存文件 (Win32 API)" + # OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR + ofn.Flags = 0x00000002 | 0x00000800 | 0x00000008 + + comdlg32 = ctypes.windll.comdlg32 + + # 调用 API + # GetSaveFileNameW 返回非零值表示成功 + if comdlg32.GetSaveFileNameW(ctypes.byref(ofn)): + return filename_buffer.value + else: + return None + + except Exception as e: + print(f"Win32 API Error: {e}") + return None + + +def show_save_dialog_com(): + """ + 使用 COM 接口 IFileSaveDialog (Windows Vista+) + 提供更现代化的文件保存对话框,支持更多功能 + """ + try: + import ctypes + import ctypes.wintypes + import uuid + + # 定义必要的常量 + CLSCTX_INPROC_SERVER = 1 + S_OK = 0 + FOS_OVERWRITEPROMPT = 0x00000002 + FOS_PATHMUSTEXIST = 0x00000800 + FOS_NOCHANGEDIR = 0x00000008 + SIGDN_FILESYSPATH = 0x80058000 + + # IFileSaveDialog 的 CLSID 和 IID + CLSID_FileSaveDialog = uuid.UUID("{C0B4E2F3-BA21-4773-8DBA-335EC946EB8B}") + IID_IFileSaveDialog = uuid.UUID("{84bccd23-5fde-4cdb-aea4-af64b83d78ab}") + IID_IShellItem = uuid.UUID("{43826d1e-e718-42ee-bc55-a1e261c37bfe}") + + # 加载 ole32.dll + ole32 = ctypes.windll.ole32 + + # CoInitialize + ole32.CoInitialize(None) + + # CoCreateInstance + p_dialog = ctypes.c_void_p() + hr = ole32.CoCreateInstance( + ctypes.byref(CLSID_FileSaveDialog), + None, + CLSCTX_INPROC_SERVER, + ctypes.byref(IID_IFileSaveDialog), + ctypes.byref(p_dialog) + ) + + if hr != S_OK: + print(f"CoCreateInstance failed: {hr}") + return None + + # 定义 IFileSaveDialog 的 vtable 方法 + # 我们只需要调用 Show, GetResult, SetOptions, SetFileName, SetDefaultExtension, SetFileTypeIndex + # 这些方法在 IFileOpenDialog 基类中定义 + + # SetOptions + class IFileSaveDialogVtbl(ctypes.Structure): + _fields_ = [ + ("QueryInterface", ctypes.c_void_p), + ("AddRef", ctypes.c_void_p), + ("Release", ctypes.c_void_p), + # IModalWindow + ("Show", ctypes.c_void_p), + # IFileDialog + ("SetFileTypes", ctypes.c_void_p), + ("SetFileTypeIndex", ctypes.c_void_p), + ("GetFileTypeIndex", ctypes.c_void_p), + ("Advise", ctypes.c_void_p), + ("Unadvise", ctypes.c_void_p), + ("SetOptions", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_ulong)), + ("GetOptions", ctypes.c_void_p), + ("SetDefaultFolder", ctypes.c_void_p), + ("SetFolder", ctypes.c_void_p), + ("GetFolder", ctypes.c_void_p), + ("GetCurrentSelection", ctypes.c_void_p), + ("SetFileName", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_wchar_p)), + ("GetFileName", ctypes.c_void_p), + ("SetTitle", ctypes.c_void_p), + ("SetOkButtonLabel", ctypes.c_void_p), + ("SetFileNameLabel", ctypes.c_void_p), + ("GetResult", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p))), + ("AddPlace", ctypes.c_void_p), + ("SetDefaultExtension", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_wchar_p)), + ("Close", ctypes.c_void_p), + ("SetClientGuid", ctypes.c_void_p), + ("ClearClientData", ctypes.c_void_p), + ("SetFilter", ctypes.c_void_p), + ] + + # 获取 vtable + vtable = ctypes.cast(p_dialog, ctypes.POINTER(ctypes.POINTER(IFileSaveDialogVtbl))).contents.contents + + # 调用 SetOptions + hr = vtable.SetOptions(p_dialog, FOS_OVERWRITEPROMPT | FOS_PATHMUSTEXIST | FOS_NOCHANGEDIR) + if hr != S_OK: + print(f"SetOptions failed: {hr}") + return None + + # 调用 SetFileName + hr = vtable.SetFileName(p_dialog, "com_save.xlsx") + if hr != S_OK: + print(f"SetFileName failed: {hr}") + return None + + # 调用 SetDefaultExtension + hr = vtable.SetDefaultExtension(p_dialog, "xlsx") + if hr != S_OK: + print(f"SetDefaultExtension failed: {hr}") + return None + + # 调用 SetFileTypeIndex + hr = vtable.SetFileTypeIndex(p_dialog, 1) + if hr != S_OK: + print(f"SetFileTypeIndex failed: {hr}") + return None + + # 调用 Show + hr = vtable.Show(p_dialog, 0) # 0 表示没有父窗口 + if hr != S_OK: + # 用户取消 + return None + + # 调用 GetResult + p_result = ctypes.c_void_p() + hr = vtable.GetResult(p_dialog, ctypes.byref(p_result)) + if hr != S_OK: + print(f"GetResult failed: {hr}") + return None + + # 定义 IShellItem 接口 + class IShellItemVtbl(ctypes.Structure): + _fields_ = [ + ("QueryInterface", ctypes.c_void_p), + ("AddRef", ctypes.c_void_p), + ("Release", ctypes.c_void_p), + ("BindToHandler", ctypes.c_void_p), + ("GetParent", ctypes.c_void_p), + ("GetDisplayName", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_wchar_p))), + ("GetAttributes", ctypes.c_void_p), + ("Compare", ctypes.c_void_p), + ] + + # 获取 IShellItem 的 vtable + result_vtable = ctypes.cast(p_result, ctypes.POINTER(ctypes.POINTER(IShellItemVtbl))).contents.contents + + # 调用 GetDisplayName + p_display_name = ctypes.c_wchar_p() + hr = result_vtable.GetDisplayName(p_result, SIGDN_FILESYSPATH, ctypes.byref(p_display_name)) + if hr != S_OK: + print(f"GetDisplayName failed: {hr}") + return None + + filepath = p_display_name.value + + # 清理 + ole32.CoUninitialize() + + return filepath + + except ImportError as e: + print(f"COM Error: {e}") + return None + except Exception as e: + print(f"COM Error: {e}") + import traceback + traceback.print_exc() + return None