feat: 优化文件保存对话框并增强系统稳定性

- 重新启用run.io_bound()调用,使用后台线程执行PowerShell脚本
- 为所有文件保存操作添加按钮防重复点击功能
- 新增win32_helper模块,提供Win32 API和COM接口的文件对话框
- 简化导出最佳方案DXF的代码结构
- 改进异步操作和错误处理机制
This commit is contained in:
dmy
2026-01-07 12:47:58 +08:00
parent e0b5b0c3dc
commit 87cea6ed86
2 changed files with 316 additions and 113 deletions

197
gui.py
View File

@@ -10,7 +10,7 @@ matplotlib.use("Agg")
import matplotlib.backends.backend_svg import matplotlib.backends.backend_svg
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd import pandas as pd
from nicegui import app, events, ui from nicegui import app, events, ui, run
from main import ( from main import (
compare_design_methods, compare_design_methods,
@@ -347,7 +347,7 @@ def index():
except Exception as ex: except Exception as ex:
ui.notify(f"上传处理失败: {ex}", type="negative") 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 filename: 默认文件名
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None :param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
:param file_filter: 格式如 "Excel Files (*.xlsx)" :param file_filter: 格式如 "Excel Files (*.xlsx)"
:param sender: 触发该操作的 UI 组件,用于在操作期间禁用以防重复点击
""" """
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows) if sender:
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError sender.disable()
import platform try:
import subprocess # 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
import platform
import subprocess
if platform.system() == "Windows": if platform.system() == "Windows":
try: try:
# 构建 PowerShell 脚本 # 构建 PowerShell 脚本
# 注意:过滤器格式为 "描述|*.ext|所有文件|*.*" # 注意:过滤器格式为 "描述|*.ext|所有文件|*.*"
ps_filter = file_filter.replace("(", "|").replace(")", "") ps_filter = file_filter.replace("(", "|").replace(")", "")
if "|" not in ps_filter: if "|" not in ps_filter:
ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}" ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}"
# 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx") # 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx")
# 这里做一个简化的映射,确保格式正确 # 这里做一个简化的映射,确保格式正确
if "Excel" in file_filter: if "Excel" in file_filter:
ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*" ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*"
elif "DXF" in file_filter: elif "DXF" in file_filter:
ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*" ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*"
elif "ZIP" in file_filter: elif "ZIP" in file_filter:
ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*" ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*"
else: else:
ps_filter = "All Files (*.*)|*.*" ps_filter = "All Files (*.*)|*.*"
ps_script = f""" ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Windows.Forms
$d = New-Object System.Windows.Forms.SaveFileDialog $d = New-Object System.Windows.Forms.SaveFileDialog
$d.Filter = "{ps_filter}" $d.Filter = "{ps_filter}"
$d.FileName = "{filename}" $d.FileName = "{filename}"
$d.Title = "保存文件" $d.Title = "保存文件"
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
Write-Output $d.FileName Write-Output $d.FileName
}} }}
""" """
# 运行 PowerShell # 运行 PowerShell
# 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁) # 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁)
startupinfo = subprocess.STARTUPINFO() startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
print("DEBUG: invoking PowerShell SaveFileDialog...") print("DEBUG: invoking PowerShell SaveFileDialog...")
# 在 native 模式下直接同步执行,不使用 run.io_bound() # 使用 run.io_bound 在后台线程执行,避免阻塞主事件循环
result = subprocess.run( # 这样按钮的禁用状态可以立即同步到前端
["powershell", "-Command", ps_script], result = await run.io_bound(
capture_output=True, subprocess.run,
text=True, ["powershell", "-Command", ps_script],
startupinfo=startupinfo capture_output=True,
) text=True,
save_path = result.stdout.strip() 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: except Exception as e:
print(f"DEBUG: PowerShell returned path: {save_path}") print(f"PowerShell dialog failed: {e}")
await callback(save_path) # 出错则回退到 ui.download
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}") print("DEBUG: Using ui.download fallback")
# 出错则回退到 ui.download temp_path = os.path.join(state["temp_dir"], filename)
await callback(temp_path)
# 统一回退方案:浏览器下载 ui.download(temp_path)
print("DEBUG: Using ui.download fallback") finally:
temp_path = os.path.join(state["temp_dir"], filename) if sender:
await callback(temp_path) sender.enable()
ui.download(temp_path)
def update_export_buttons(): def update_export_buttons():
if refs["export_row"]: if refs["export_row"]:
@@ -468,9 +475,9 @@ def index():
# 如果不存在,重新生成 # 如果不存在,重新生成
export_all_scenarios_to_excel(state["results"], path) export_all_scenarios_to_excel(state["results"], path)
async def on_click_excel(): async def on_click_excel(e):
await save_file_with_dialog( 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( ui.button(
@@ -479,41 +486,7 @@ def index():
).props("icon=download") ).props("icon=download")
# --- 导出推荐方案 DXF --- # --- 导出推荐方案 DXF ---
def export_best_dxf(): async def on_click_best_dxf(e):
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():
if state["substation"] is not None: if state["substation"] is not None:
safe_name = "".join( safe_name = "".join(
[ [
@@ -533,7 +506,7 @@ def index():
) )
await save_file_with_dialog( await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)" default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender
) )
else: else:
ui.notify("缺少升压站数据,无法导出 DXF", type="negative") ui.notify("缺少升压站数据,无法导出 DXF", type="negative")
@@ -543,7 +516,7 @@ def index():
).props("icon=architecture color=accent") ).props("icon=architecture color=accent")
# --- 导出选中方案 DXF --- # --- 导出选中方案 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: if not refs["results_table"] or not refs["results_table"].selected:
ui.notify("请先在上方表格中选择一个方案", type="warning") ui.notify("请先在上方表格中选择一个方案", type="warning")
return return
@@ -573,7 +546,7 @@ def index():
) )
await save_file_with_dialog( await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)" default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender
) )
else: else:
ui.notify( ui.notify(
@@ -588,7 +561,7 @@ def index():
refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})") refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})")
# --- 导出全部 ZIP --- # --- 导出全部 ZIP ---
async def on_click_all_dxf(): async def on_click_all_dxf(e):
if not state["results"] or state["substation"] is None: if not state["results"] or state["substation"] is None:
ui.notify("无方案数据可导出", type="warning") ui.notify("无方案数据可导出", type="warning")
return return
@@ -634,7 +607,7 @@ def index():
except: except:
pass 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( ui.button("导出全部方案 DXF (ZIP)", on_click=on_click_all_dxf).props(
"icon=folder_zip color=secondary" "icon=folder_zip color=secondary"
@@ -688,8 +661,6 @@ def index():
import queue import queue
from nicegui import run
async def run_analysis(): async def run_analysis():
if not state["excel_path"]: if not state["excel_path"]:
ui.notify("请先上传 Excel 坐标文件!", type="warning") ui.notify("请先上传 Excel 坐标文件!", type="warning")
@@ -879,7 +850,7 @@ def index():
# 使用 items-stretch 确保所有子元素高度一致 # 使用 items-stretch 确保所有子元素高度一致
with ui.row().classes("w-full items-stretch gap-4"): with ui.row().classes("w-full items-stretch gap-4"):
# 1. 导出模板按钮 # 1. 导出模板按钮
async def export_template(): async def export_template(e):
import shutil import shutil
from generate_template import create_template from generate_template import create_template
@@ -900,7 +871,7 @@ def index():
raise FileNotFoundError("无法生成模板文件") raise FileNotFoundError("无法生成模板文件")
await save_file_with_dialog( 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( ui.button("导出 Excel 模板", on_click=export_template).classes(

232
win32_helper.py Normal file
View File

@@ -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